webdriver-installer
Version:
Install the right WebDriver version for your local browsers, automatically.
418 lines (378 loc) • 12.3 kB
JavaScript
/*! @license
* WebDriver Installer
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const childProcess = require('child_process');
const fetch = require('node-fetch');
const fs = require('fs');
const fsPromises = require('fs').promises;
const os = require('os');
const path = require('path');
const regedit = require('regedit');
const stream = require('stream');
const tar = require('tar-stream');
const util = require('util');
const yauzl = require('yauzl');
const zlib = require('zlib');
const regQuery = util.promisify(regedit.list);
const execFile = util.promisify(childProcess.execFile);
const pipeline = util.promisify(stream.pipeline);
const zipFromBuffer = util.promisify(yauzl.fromBuffer);
const WINDOWS_REGISTRY_APP_PATHS =
'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\App\ Paths\\';
/**
* A static utility class for driver installers to use for common operations.
*/
class InstallerUtils {
/**
* Execute an external command to get a result containing .stdout and .stderr.
* All output is interpretted as UTF-8.
*
* Throws if the command fails. If the command does not exist, the thrown
* error has .code == 'ENOENT'.
*
* @param {!Array<string>} args
* @return {!Promise<!Object>} as returned by child_process.spawn
*/
static async runCommand(args) {
return await execFile(args[0], args.slice(1), {encoding: 'utf8'});
}
/**
* Execute a command and return its stdout. If the command is missing,
* returns null. If the command fails, throws.
*
* @param {!Array<string>} args
* @return {!Promise<?string>}
*/
static async getCommandOutputOrNullIfMissing(args) {
try {
const result = await InstallerUtils.runCommand(args);
// Success. Return command's stdout.
return result.stdout;
} catch (error) {
if (error.code == 'ENOENT') {
// Command does not exist.
return null;
} else if (error.code == 'EACCES') { // Missing "s" is not a typo!
// Command is not executable. This can happen after a failed run that
// downloads something, but is interrupted before setting its
// executable bit.
return null;
} else {
// Command exists, but failed.
throw error;
}
}
}
/**
* Fetch a URL, throwing if the HTTP status code is not 2XX.
*
* @param {string} url
* @return {!Promise<!Response>}
*/
static async fetchUrl(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch ${url}: ${response.status} ${response.statusText}`,
{ cause: response });
}
return response;
}
/**
* Get a version number from the Windows registry.
*
* @param {string} regPath
* @param {string} key
* @return {!Promise<?string>}
*/
static async getWindowsRegistryVersion(regPath, key) {
if (os.platform() != 'win32') {
return null;
}
// Try the 64-bit registry first, then fall back to the 32-bit registry.
// Necessary values could be in either location.
let result = await regQuery(regPath, '64');
if (!result[regPath].exists || !result[regPath].values[key]) {
result = await regQuery(regPath, '32');
}
if (!result[regPath].exists || !result[regPath].values[key]) {
return null;
}
return result[regPath].values[key].value;
}
/**
* Test if a file exists.
*
* @param {filePath}
* @return {!Promise<boolean}
*/
static async fileExists(filePath) {
try {
await fsPromises.stat(filePath);
return true;
} catch (error) {
return false;
}
}
/**
* Get a version number from the metadata of a Windows executable.
*
* @param {string} executablePath
* @return {!Promise<?string>}
*/
static async getWindowsExeVersion(executablePath) {
if (os.platform() != 'win32') {
return null;
}
if (!(await InstallerUtils.fileExists(executablePath))) {
// No such file.
// If it's a relative path, ask the registry for a full one.
if (!executablePath.includes('/') && !executablePath.includes('\\')) {
executablePath = await InstallerUtils.getWindowsRegistryVersion(
WINDOWS_REGISTRY_APP_PATHS + executablePath,
'');
if (!executablePath ||
!(await InstallerUtils.fileExists(executablePath))) {
return null;
}
}
}
const result = await InstallerUtils.runCommand([
'powershell',
`(Get-Item "${executablePath}").VersionInfo.ProductVersion`,
]);
const output = result.stdout.trim();
if (output == '') {
// No such metadata found by powershell.
return null;
}
return output;
}
/**
* Get the version of an installed app on macOS.
*
* @param {string} appName
* @return {!Promise<?string>}
*/
static async getMacAppVersion(appName) {
if (os.platform() != 'darwin') {
return null;
}
try {
const result = await InstallerUtils.runCommand([
'osascript', '-e',
`tell application "${appName}" to get version`,
]);
return result.stdout.trim();
} catch (error) {
return null;
}
}
/**
* Get the version of an installed Android app on a connected device.
*
* @param {string} appReverseDomainName
* @return {!Promise<?string>}
*/
static async getAndroidAppVersion(appReverseDomainName) {
let result;
try {
result = await InstallerUtils.runCommand([
'adb', 'shell', 'dumpsys', `package ${appReverseDomainName}`,
]);
} catch (error) {
if (error.code == 'ENOENT') {
// No adb, so no Android connection.
return null;
} else if (error.code != 0) {
if (error.stderr.includes('no devices')) {
// No devices attached.
return null;
}
// Otherwise, print the error output and raise an error.
process.stderr.write(error.stderr);
throw new Error(`Failed to communicate with adb!`);
}
}
for (const line of result.stdout.split('\n')) {
// Something like:
// versionName=1.2.3
if (line.includes('versionName')) {
return line.split('=')[1];
}
}
return null;
}
/**
* Fetch a version number from a URL.
*
* @param {string} url
* @param {string=} encoding
* @return {!Promise<?string>}
*/
static async fetchVersionUrl(url, encoding='utf8') {
const response = await InstallerUtils.fetchUrl(url);
const data = Buffer.from(await response.arrayBuffer());
// Decode the string, then remove any newlines.
return data.toString(encoding).trim();
}
/**
* Fetch a version number from a URL. If not found, downgrade the major
* version and try again. If a WebDriver release lags the browser release
* (which seems common), this will compensate. Both Chrome and Edge use
* this.
*
* @param {number} idealMajorVersion
* @param {number} minMajorVersion
* @param {function(number): string} urlFormatter
* @param {string=} encoding
* @return {!Promise<string>}
*/
static async fetchVersionUrlWithAutomaticDowngrade(
idealMajorVersion, minMajorVersion, urlFormatter, encoding) {
let majorVersion = idealMajorVersion;
let firstError = null;
while (majorVersion >= minMajorVersion) {
const versionUrl = urlFormatter(majorVersion);
try {
return await InstallerUtils.fetchVersionUrl(versionUrl, encoding);
} catch (error) {
if (error.cause.status != 404) {
// Any unexpected error (other than HTTP 404) is thrown immediately.
throw error;
}
// Save the first error in case we run out this loop. We'll throw this
// one if none of the allowed versions can be found.
if (firstError == null) {
firstError = error;
}
// For 404 errors, decrease the major version, fall through, loop, and
// try again.
majorVersion--;
}
}
// We tried all allowed versions. Throw the initial error, which will have
// details of the first URL we tried.
throw firstError;
}
/**
* Fetch the latest tag from a GitHub repo.
*
* @param {string} repo
* @return {?string}
*/
static async fetchLatestGitHubTag(repo) {
// The GitHub API has rate limits, but this is public. It will redirect to
// a URL specific to the tag.
const url = `https://github.com/${repo}/releases/latest`;
const response = await fetch(url, {method: 'HEAD'});
// The redirected URL will be something like:
// "https://github.com/mozilla/geckodriver/releases/tag/v0.30.0"
return response.url.split('/').pop();
}
/**
* Find a specific entry in a zip file.
*
* @param {!YauzlZipFile} zipfile
* @param {string} nameInArchive
* @return {!Promise<!YauzlZipEntry>}
*/
static findZipEntry(zipfile, nameInArchive) {
return new Promise((resolve, reject) => {
zipfile.on('entry', (entry) => {
if (entry.fileName == nameInArchive) {
// Found it!
resolve(entry);
} else {
// Read the next one.
zipfile.readEntry();
}
});
zipfile.on('end', () => {
// Reached the end without finding our target.
reject(new Error(`Failed to find ${nameInArchive} in zip file!`));
});
// Kick-start the reading process.
zipfile.readEntry();
});
}
/**
* Find a specific entry in a tar file.
*
* @param {!Buffer} buffer
* @param {string} nameInArchive
* @return {!Promise<!Stream>}
*/
static findTarEntry(buffer, nameInArchive) {
return new Promise((resolve, reject) => {
const extract = tar.extract();
extract.on('entry', (entry, stream, next) => {
if (entry.name == nameInArchive) {
// Found it!
resolve(stream);
} else {
// Drain the stream so that tar parsing can continue.
stream.on('end', next);
stream.resume();
}
});
extract.on('finish', () => {
// Reached the end without finding our target.
reject(new Error(`Failed to find ${nameInArchive} in tar file!`));
});
// Push the buffer through the decompression and extraction pipeline.
const gunzip = zlib.createGunzip();
gunzip.pipe(extract);
gunzip.end(buffer);
});
}
/**
* Extract a file from an archive by URL.
*
* @param {string} url
* @param {string} nameInArchive
* @param {string} outputPath
* @param {boolean} isZip
*/
static async extractFromNetworkArchive(
url, nameInArchive, outputPath, isZip) {
const response = await InstallerUtils.fetchUrl(url);
const buffer = Buffer.from(await response.arrayBuffer());
// If the output file already exists, remove it before overwriting it.
// This is important if it's a running executable. Otherwise, we might get
// permission errors overwriting it. Unlinking it first will ensure the
// newly-written file is a fresh filesystem inode that doesn't conflict
// with what's running.
if (await InstallerUtils.fileExists(outputPath)) {
await fsPromises.unlink(outputPath);
}
if (isZip) {
const zipfile = await zipFromBuffer(buffer, {
lazyEntries: true,
});
const entry = await InstallerUtils.findZipEntry(zipfile, nameInArchive);
const openReadStream = util.promisify(
zipfile.openReadStream.bind(zipfile));
await pipeline(
await openReadStream(entry),
fs.createWriteStream(outputPath),
);
} else {
const stream = await InstallerUtils.findTarEntry(buffer, nameInArchive);
await pipeline(
stream,
fs.createWriteStream(outputPath),
);
}
}
static async installBinary(
url, nameInArchive, outputName, outputDirectory, isZip) {
const outputPath = path.join(outputDirectory, outputName);
await InstallerUtils.extractFromNetworkArchive(
url, nameInArchive, outputPath, isZip);
await fsPromises.chmod(outputPath, 0o755);
}
}
module.exports = {InstallerUtils};