Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Move WAD server installation into a driver script #277

Merged
merged 14 commits into from
Sep 16, 2024
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Beside of standard Appium requirements Appium Windows Driver adds the following

- Appium Windows Driver only supports Windows 10 as the host.
- [Developer mode](https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development) must be enabled
- Appium downloads and installs WinAppDriver package automatically upon executing its installation scripts, although, the actual binary version could be out of date. In such case you could download and install the most recent version of WinAppDriver manually from the [GitHub releases](https://github.com/microsoft/WinAppDriver/releases) page.
- Since version 3.0.0 this driver **does not** automatically install WinAppDriver server anymore. You should perform the server installation via the [install-wad](#install-wad) driver script instead. Driver versions below 3.0.0 download and install a bundled WinAppDriver package version automatically upon executing its installation via the Appium command line interface. Although, in such case the actual server binary version could be out of date. You could download and install the most recent version of WinAppDriver server manually from the [GitHub releases](https://github.com/microsoft/WinAppDriver/releases) page.

Appium Windows Driver supports the following capabilities:

Expand All @@ -44,6 +44,15 @@ appium:prerun | An object containing either `script` or `command` key. The value
appium:postrun | An object containing either `script` or `command` key. The value of each key must be a valid PowerShell script or command to be executed after WinAppDriver session is stopped. See [Power Shell commands execution](#power-shell-commands-execution) for more details.
appium:newCommandTimeout | How long (in seconds) the driver should wait for a new command from the client before assuming the client has stopped sending requests. After the timeout, the session is going to be deleted. `60` seconds by default. Setting it to zero disables the timer.

## Driver Scripts

### install-wad

This script is used to install the given or latest stable version of WinAppDriver server from
the [GitHub releases](https://github.com/microsoft/WinAppDriver/releases) page.
Run `appium driver run windows install-wad <optional_wad_version>`, where `optional_wad_version`
must be either valid WAD version number or should not be present (the latest stable version is used then).

## Example

```python
Expand Down
32 changes: 0 additions & 32 deletions install-npm.js

This file was deleted.

73 changes: 3 additions & 70 deletions lib/installer.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import _ from 'lodash';
import { system, fs, util, net, tempDir } from 'appium/support';
import { fs, tempDir } from 'appium/support';
import path from 'path';
import { exec } from 'teen_process';
import log from './logger';
import { queryRegistry } from './registry';
import { shellExec } from './utils';

// https://github.com/microsoft/WinAppDriver/releases
const WAD_VER = '1.2.99';
const WAD_DOWNLOAD_MD5 = Object.freeze({
x32: '23745e6ed373bc969ff7c4493e32756a',
x64: '2923fc539f389d47754a7521ee50108e',
arm64: 'b9af4222a3fb0d688ecfbf605d1c4500',
});
const ARCH_MAPPING = Object.freeze({x32: 'x86', x64: 'x64', arm64: 'arm64'});
const WAD_DOWNLOAD_TIMEOUT_MS = 60000;
const POSSIBLE_WAD_INSTALL_ROOTS = [
process.env['ProgramFiles(x86)'],
process.env.ProgramFiles,
Expand All @@ -33,17 +24,6 @@ session.DoAction("CostFinalize")
WScript.Echo session.Property("INSTALLFOLDER")
`.replace(/\n/g, '\r\n');


function generateWadDownloadLink () {
const wadArch = ARCH_MAPPING[process.arch];
if (!wadArch) {
throw new Error(`System architecture '${process.arch}' is not supported by Windows Application Driver. ` +
`The only supported architectures are: ${_.keys(ARCH_MAPPING)}`);
}
return `https://github.com/Microsoft/WinAppDriver` +
`/releases/download/v${WAD_VER}/WindowsApplicationDriver-${WAD_VER}-win-${wadArch}.exe`;
}

async function fetchMsiInstallLocation (installerGuid) {
const tmpRoot = await tempDir.openDir();
const scriptPath = path.join(tmpRoot, 'get_wad_inst_location.vbs');
Expand All @@ -58,7 +38,7 @@ async function fetchMsiInstallLocation (installerGuid) {

class WADNotFoundError extends Error {}

const getWADExecutablePath = _.memoize(async function getWADInstallPath () {
export const getWADExecutablePath = _.memoize(async function getWADInstallPath () {
const wadPath = process.env.APPIUM_WAD_PATH ?? '';
if (await fs.exists(wadPath)) {
log.debug(`Loaded WinAppDriver path from the APPIUM_WAD_PATH environment variable: ${wadPath}`);
Expand Down Expand Up @@ -108,58 +88,11 @@ const getWADExecutablePath = _.memoize(async function getWADInstallPath () {
`locations: ${pathCandidates}. Is it installed?`);
});

async function downloadWAD () {
const downloadLink = generateWadDownloadLink();
const installerPath = path.resolve(await tempDir.staticDir(),
`wad_installer_${WAD_VER}_${util.uuidV4()}.exe`);
log.info(`Downloading ${downloadLink} to '${installerPath}'`);
await net.downloadFile(downloadLink, installerPath, {timeout: WAD_DOWNLOAD_TIMEOUT_MS});
const downloadedMd5 = await fs.md5(installerPath);
const expectedMd5 = WAD_DOWNLOAD_MD5[process.arch];
if (downloadedMd5 !== expectedMd5) {
await fs.rimraf(installerPath);
throw new Error(
`Installer executable checksum validation error: expected ${expectedMd5} but got ${downloadedMd5}`
);
}
return installerPath;
}

const isAdmin = async function isAdmin () {
export async function isAdmin () {
try {
await exec('fsutil.exe', ['dirty', 'query', process.env.SystemDrive || 'C:']);
return true;
} catch (ign) {
return false;
}
};

async function setupWAD () {
if (!system.isWindows()) {
throw new Error(`Can only download WinAppDriver on Windows!`);
}

try {
return await getWADExecutablePath();
} catch (e) {
if (!(e instanceof WADNotFoundError)) {
throw e;
}
log.info(`WinAppDriver doesn't exist, setting up`);
}

const installerPath = await downloadWAD();
log.info(`Running WinAppDriver v${WAD_VER} installer`);
try {
await shellExec(installerPath, ['/install', '/quiet', '/norestart']);
} catch (e) {
/** @type {import('node:child_process').ExecException} */
const error = e;
throw new Error(`WinAppDriver cannot be installed: ${error.message}. Exit code: ${error.code}`);
} finally {
await fs.rimraf(installerPath);
}
}

export { downloadWAD, setupWAD, getWADExecutablePath, isAdmin };
export default setupWAD;
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
"platformNames": [
"Windows"
],
"scripts": {
"install-wad": "./scripts/install-wad.mjs"
},
"mainClass": "WindowsDriver"
},
"files": [
mykola-mokhnach marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -50,10 +53,13 @@
"npm-shrinkwrap.json"
],
"dependencies": {
"@appium/support": "^5.1.3",
"asyncbox": "^3.0.0",
"axios": "^1.7.7",
"bluebird": "^3.5.1",
"lodash": "^4.6.1",
"portscanner": "^2.2.0",
"semver": "^7.6.3",
"source-map-support": "^0.x",
"teen_process": "^2.0.1"
},
Expand All @@ -64,7 +70,6 @@
"lint": "eslint .",
"lint:fix": "npm run lint -- --fix",
"prepare": "npm run build",
"install": "node install-npm.js",
"test": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.js\"",
"e2e-test": "mocha --exit --timeout 10m \"./test/e2e/**/*-specs.js\""
},
Expand Down
157 changes: 157 additions & 0 deletions scripts/install-wad.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import axios from 'axios';
import semver from 'semver';
import _ from 'lodash';
import { logger, net, tempDir, fs, util } from '@appium/support';
import path from 'node:path';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import B from 'bluebird';

const log = logger.getLogger('WAD Installer');
const OWNER = 'microsoft';
const REPO = 'winappdriver';
const API_ROOT = `https://api.github.com/repos/${OWNER}/${REPO}`;
const timeoutMs = 15 * 1000;
const ASSET_NAME = 'WindowsApplicationDriver.msi';
const STABLE_VERSION = 'stable';
const execAsync = promisify(exec);

/**
* This API triggers UAC when necessary
* unlike the 'spawn' call used by teen_process's exec.
* See https://github.com/nodejs/node-v0.x-archive/issues/6797
*
* @param {string} cmd
* @param {string[]} args
* @param {import('node:child_process').ExecOptions & {timeoutMs?: number}} opts
* @returns {Promise<{stdout: string; stderr: string;}>}
* @throws {import('node:child_process').ExecException}
*/
async function shellExec(cmd, args = [], opts = {}) {
const {
timeoutMs = 60 * 1000 * 5
} = opts;
const fullCmd = util.quote([cmd, ...args]);
return await B.resolve(execAsync(fullCmd, opts))
.timeout(timeoutMs, `The command '${fullCmd}' timed out after ${timeoutMs}ms`);
}

/**
*
* @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(/^<|>$/, '');
}
}
return null;
}

/**
* 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: timeoutMs
});
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/, ''));
const downloadUrl = releaseInfo.assets?.[0]?.browser_download_url;
const assetName = releaseInfo.assets?.[0]?.name;
if (!version || !downloadUrl || !_.endsWith(assetName, '.msi')) {
continue;
}
result.push({
version,
isDraft,
isPrerelease,
downloadUrl,
});
}
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 WinAppDriver 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 WinAppDriver releases: ` +
JSON.stringify(releases)
);
}
return dstRelease;
}

/**
*
* @param {string} version
* @returns {Promise<void>}
*/
async function installWad(version) {
log.debug(`Retrieving WinAppDriver releases from ${API_ROOT}`);
const releases = await listReleases();
if (!releases.length) {
throw new Error(`Cannot retrieve any valid WinAppDriver releases from GitHub`);
}
log.debug(`Retrieved ${util.pluralize('WinAppDriver GitHub release', releases.length, true)}`);
const release = selectRelease(releases, version);
log.info(`Will download and install WinAppDriver (${JSON.stringify(release)})`);
const tmpRoot = await tempDir.openDir();
const installerPath = path.join(tmpRoot, ASSET_NAME);
try {
await net.downloadFile(release.downloadUrl, installerPath);
await shellExec(installerPath, ['/install', '/quiet', '/norestart']);
} finally {
await fs.rimraf(tmpRoot);
}
}

(async () => await installWad(process.argv[2] ?? STABLE_VERSION))();

/**
* @typedef {Object} ReleaseInfo
* @property {import('semver').SemVer} version
* @property {boolean} isDraft
* @property {boolean} isPrerelease
* @property {string} downloadUrl
*/
36 changes: 0 additions & 36 deletions test/e2e/installer-e2e-specs.js

This file was deleted.

Loading
Loading