appium-geckodriver
Version:
Appium driver for Gecko-based browsers and web views
291 lines (275 loc) • 8.62 kB
JavaScript
import axios from 'axios';
import * as semver from 'semver';
import _ from 'lodash';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { log } from '../build/lib/logger.js';
import {
downloadToFile,
mkdirp,
extractFileFromTarGz,
extractFileFromZip,
} from '../build/lib/utils.js';
import fs from 'node:fs/promises';
import { exec } from 'teen_process';
const OWNER = 'mozilla';
const REPO = 'geckodriver';
const API_ROOT = `https://api.github.com/repos/${OWNER}/${REPO}`;
const API_VERSION_HEADER = {'X-GitHub-Api-Version': '2022-11-28'};
const API_TIMEOUT_MS = 45 * 1000;
const STABLE_VERSION = 'stable';
const EXT_TAR_GZ = '.tar.gz';
const EXT_ZIP = '.zip';
const EXT_REGEXP = new RegExp(`(${_.escapeRegExp(EXT_TAR_GZ)}|${_.escapeRegExp(EXT_ZIP)})$`);
const ARCHIVE_NAME_PREFIX = 'geckodriver-v';
const ARCH_MAPPING = Object.freeze({
ia32: '32',
x64: '64',
arm64: 'aarch64',
});
const PLATFORM_MAPPING = Object.freeze({
win32: 'win',
darwin: 'macos',
linux: 'linux',
});
/**
*
* @param {string} dstPath
* @returns {Promise<void>}
*/
async function clearNotarization(dstPath) {
if (process.platform === 'darwin') {
await exec('xattr', ['-cr', dstPath]);
}
}
/**
*
* @param {import('axios').AxiosResponseHeaders} headers
* @returns {string|null}
*/
function parseNextPageUrl(headers) {
if (!headers.link) {
return null;
}
for (const part of headers.link.split(';')) {
const [rel, pageUrl] = part.split(',').map(_.trim);
if (rel === 'rel="next"' && pageUrl) {
return pageUrl.replace(/^<|>$/g, '');
}
}
return null;
}
/**
* @returns {Promise<[string, boolean]>}
*/
async function prepareDestinationFolder() {
let dstRoot;
switch (process.platform) {
case 'win32':
dstRoot = path.join(process.env.LOCALAPPDATA, 'Mozilla');
break;
case 'linux':
case 'darwin':
dstRoot = path.join('/usr', 'local', 'bin');
break;
default:
throw new Error(
`GeckoDriver does not support the ${process.platform} platform. ` +
`Only Linux, Windows and macOS are supported.`
);
}
await mkdirp(dstRoot);
const pathParts = process.env.PATH ? process.env.PATH.split(path.delimiter) : [];
const isInPath = pathParts
.map((pp) => path.normalize(pp))
.some((pp) => pp === path.normalize(dstRoot));
return [dstRoot, isInPath];
}
/**
* https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#list-releases
*
* @returns {Promise<ReleaseInfo[]}
*/
async function listReleases() {
/** @type {Record<string, any>[]} */
const allReleases = [];
let currentUrl = `${API_ROOT}/releases`;
do {
const {data, headers} = await axios.get(currentUrl, {
timeout: API_TIMEOUT_MS,
headers: { ...API_VERSION_HEADER }
});
allReleases.push(...data);
currentUrl = parseNextPageUrl(headers);
} while (currentUrl);
/** @type {ReleaseInfo[]} */
const result = [];
for (const releaseInfo of allReleases) {
const isDraft = !!releaseInfo.draft;
const isPrerelease = !!releaseInfo.prerelease;
const version = semver.coerce(releaseInfo.tag_name?.replace(/^v/, ''));
if (!version) {
continue;
}
/** @type {ReleaseAsset[]} */
const releaseAssets = [];
for (const asset of (releaseInfo.assets ?? [])) {
const assetName = asset?.name;
const downloadUrl = asset?.browser_download_url;
if (
!_.startsWith(assetName, ARCHIVE_NAME_PREFIX)
|| !(_.endsWith(assetName, EXT_TAR_GZ) || _.endsWith(assetName, EXT_ZIP))
|| !downloadUrl
) {
continue;
}
releaseAssets.push({
name: assetName,
url: downloadUrl,
});
}
result.push({
version,
isDraft,
isPrerelease,
assets: releaseAssets,
});
}
return result;
}
/**
* @param {ReleaseInfo[]} releases
* @param {string} version
* @returns {ReleaseInfo}
*/
function selectRelease(releases, version) {
if (version === STABLE_VERSION) {
const stableReleasesAsc = releases
.filter(({isDraft, isPrerelease}) => !isDraft && !isPrerelease)
.toSorted((a, b) => a.version.compare(b.version));
const dstRelease = _.last(stableReleasesAsc);
if (!dstRelease) {
throw new Error(`Cannot find any stable GeckoDriver release: ${JSON.stringify(releases)}`);
}
return dstRelease;
}
const coercedVersion = semver.coerce(version);
if (!coercedVersion) {
throw new Error(`The provided version string '${version}' cannot be coerced to a valid SemVer representation`);
}
const dstRelease = releases.find((r) => r.version.compare(coercedVersion) === 0);
if (!dstRelease) {
throw new Error(
`The provided version string '${version}' cannot be matched to any available GeckoDriver releases: ` +
JSON.stringify(releases)
);
}
return dstRelease;
}
/**
*
* @param {ReleaseInfo} release
* @returns {ReleaseAsset}
*/
function selectAsset(release) {
if (_.isEmpty(release.assets)) {
throw new Error(`GeckoDriver v${release.version} does not contain any matching releases`);
}
const dstPlatform = PLATFORM_MAPPING[process.platform];
const dstArch = ARCH_MAPPING[process.arch];
log.info(`Operating system: ${process.platform}@${process.arch}`);
/** @type {(filterFunc: (string) => boolean) => null|ReleaseAsset} */
const findAssetMatch = (filterFunc) => {
for (const asset of release.assets) {
if (!dstPlatform || !_.includes(asset.name, `-${dstPlatform}`)) {
continue;
}
const nameWoExt = asset.name.replace(EXT_REGEXP, '');
if (filterFunc(nameWoExt)) {
return asset;
}
}
return null;
};
// Try to find an exact match
const exactMatch = findAssetMatch(
(nameWoExt) =>
(dstArch === 'aarch64' && _.endsWith(nameWoExt, `-${dstArch}`))
|| (['64', '32'].includes(dstArch) && _.endsWith(nameWoExt, `-${dstPlatform}${dstArch}`))
);
if (exactMatch) {
return exactMatch;
}
// If no exact match has been been found then try a loose one
const looseMatch = findAssetMatch(
(nameWoExt) =>
_.endsWith(nameWoExt, `-${dstPlatform}`)
|| (dstArch === '64' && _.endsWith(nameWoExt, `-${dstPlatform}32`))
);
if (looseMatch) {
return looseMatch;
}
throw new Error(
`GeckoDriver v${release.version} does not contain any release matching the ` +
`current OS architecture ${process.arch}. Available packages: ${release.assets.map(({name}) => name)}`
);
}
/**
*
* @param {string} version
* @returns {Promise<void>}
*/
async function installGeckodriver(version) {
log.debug(`Retrieving releases from ${API_ROOT}`);
const releases = await listReleases();
if (!releases.length) {
throw new Error(`Cannot retrieve any valid GeckoDriver releases from GitHub`);
}
log.debug(`Retrieved ${releases.length} GitHub releases`);
const release = selectRelease(releases, version);
const asset = selectAsset(release);
const [dstFolder, isInPath] = await prepareDestinationFolder();
if (!isInPath) {
log.warning(
`The folder '${dstFolder}' is not present in the PATH environment variable. ` +
`Please add it there manually before starting a session.`
);
}
const archiveName = asset.name.replace(EXT_REGEXP, '');
const archivePath = path.join(
tmpdir(),
`${archiveName}_${(Math.random() + 1).toString(36).substring(7)}${asset.name.replace(archiveName, '')}`
);
log.info(`Will download and install v${release.version} from ${asset.url}`);
try {
await downloadToFile(asset.url, archivePath);
let executablePath;
if (archivePath.endsWith(EXT_TAR_GZ)) {
executablePath = path.join(dstFolder, 'geckodriver');
await extractFileFromTarGz(archivePath, path.basename(executablePath), executablePath);
} else {
// .zip is only used for Windows
executablePath = path.join(dstFolder, 'geckodriver.exe');
await extractFileFromZip(archivePath, path.basename(executablePath), executablePath);
}
await clearNotarization(executablePath);
log.info(`The driver is now available at '${executablePath}'`);
} finally {
try {
await fs.unlink(archivePath);
} catch {}
}
}
(async () => await installGeckodriver(process.argv[2] ?? STABLE_VERSION))();
/**
* @typedef {Object} ReleaseAsset
* @property {string} name
* @property {string} url
*/
/**
* @typedef {Object} ReleaseInfo
* @property {import('semver').SemVer} version
* @property {boolean} isDraft
* @property {boolean} isPrerelease
* @property {ReleaseAsset[]} assets
*/