feat: add driver update cli command
MODIFY
lib/cli/args.js
lib/cli/args.js
@@ -380,7 +380,14 @@ const driverUpdateArgs = [
[['driver'], {
type: 'string',
example: 'xcuitest',
- help: 'Name of the driver to update, or the word "all" to update all. To see available updates, run "appium driver list --installed --updates"',
+ help: 'Name of the driver to update, or the word "installed" to update all installed drivers. To see available updates, run "appium driver list --installed --updates"',
+ }],
+ [['--unsafe'], {
+ required: false,
+ defaultValue: false,
+ action: 'storeTrue',
+ nargs: 0,
+ help: 'Include updates that might have a new major revision, and potentially include breaking changes',
}],
];

MODIFY
lib/cli/driver.js
lib/cli/driver.js
@@ -9,13 +9,17 @@ import NPM from './npm';
import { errAndQuit, log, spinWith, JSON_SPACES } from './utils';
import { util, fs } from 'appium-support';
+const UPDATE_ALL = 'installed';
const REQ_DRIVER_FIELDS = ['driverName', 'automationName', 'platformNames', 'mainClass'];
+class NotUpdatableError extends Error {}
+class NoUpdatesAvailableError extends Error {}
+
/**
* Run a subcommand of the 'appium driver' type. Each subcommand has its own set of arguments which
* can be represented as a JS object.
*
- * @param [object] args - JS object where the key is the parameter name (as defined in
+ * @param {Object} args - JS object where the key is the parameter name (as defined in
* driver-parser.js)
*/
async function runDriverCommand (args) {
@@ -75,15 +79,17 @@ class DriverCommand {
if (!showUpdates) {
return;
}
- for (const [, data] of _.toPairs(drivers)) {
- const {version, pkgName, installType} = data;
- data.updateVersion = null;
- if (installType === INSTALL_TYPE_NPM) {
- const latestVersion = await this.npm.getLatestVersion(pkgName);
- if (!version || util.compareVersions(latestVersion, '>', version)) {
- data.updateVersion = latestVersion;
- }
+ for (const [driver, data] of _.toPairs(drivers)) {
+ const {installed, installType} = data;
+ if (!installed || installType !== INSTALL_TYPE_NPM) {
+ // don't need to check for updates on drivers that aren't installed
+ // also don't need to check for updates on non-npm drivers
+ continue;
}
+ const updates = await this.checkForDriverUpdate(driver);
+ data.updateVersion = updates.safeUpdate;
+ data.unsafeUpdateVersion = updates.unsafeUpdate;
+ data.upToDate = updates.safeUpdate === null && updates.unsafeUpdate === null;
}
});
@@ -95,7 +101,7 @@ class DriverCommand {
for (const [
name,
- {installType, installSpec, installed, updateVersion, version}
+ {installType, installSpec, installed, updateVersion, unsafeUpdateVersion, version, upToDate}
] of _.toPairs(drivers)) {
let typeTxt;
switch (installType) {
@@ -115,8 +121,14 @@ class DriverCommand {
const updateTxt = showUpdates && updateVersion ?
` [${updateVersion} available]`.magenta :
'';
+ const upToDateTxt = showUpdates && upToDate ?
+ ` [Up to date]`.green :
+ '';
+ const unsafeUpdateTxt = showUpdates && unsafeUpdateVersion ?
+ ` [${unsafeUpdateVersion} available (potentially unsafe)]`.cyan :
+ '';
- console.log(`- ${name.yellow}${installTxt}${updateTxt}`);
+ console.log(`- ${name.yellow}${installTxt}${updateTxt}${upToDateTxt}${unsafeUpdateTxt}`);
}
return drivers;
@@ -262,12 +274,149 @@ class DriverCommand {
return this.config.installedDrivers;
}
- update (/*{driver}*/) {
- // TODO implementation
- return {};
+ /**
+ * @typedef {Object} DriverUpdateOpts
+ * @property {string} driver - the name of the driver to update
+ * @property {boolean} unsafe - if true, will perform unsafe updates past major revision
+ * boundaries
+ */
+
+ /**
+ * @typedef {Object} UpdateReport
+ * @property {string} from - version updated from
+ * @property {string} to - version updated to
+ */
+
+ /**
+ * @typedef {Object} DriverUpdateResult
+ * @property {Object} errors - map of driver names to error objects
+ * @property {Object} updates - map of driver names to {@link UpdateReport}s
+ */
+
+ /**
+ * Attempt to update one or more drivers using NPM
+ *
+ * @param {DriverUpdateOpts} updateSpec
+ * @returns {DriverUpdateResult}
+ */
+ async update ({driver, unsafe}) {
+ const shouldUpdateAll = driver === UPDATE_ALL;
+ // if we're specifically requesting an update for a driver, make sure it's installed
+ if (!shouldUpdateAll && !this.config.isInstalled(driver)) {
+ throw new Error(`Driver '${driver}' was not installed, so can't be updated`);
+ }
+ const driversToUpdate = shouldUpdateAll ? Object.keys(this.config.installedDrivers) : [driver];
+
+ // 'errors' will have driver names as keys and error objects as values
+ const errors = {};
+
+ // 'updates' will have driver names as keys and update objects as values, where an update
+ // object is of the form {from: versionString, to: versionString}
+ const updates = {};
+
+ for (const d of driversToUpdate) {
+ try {
+ await spinWith(this.isJsonOutput, `Checking if driver '${d}' is updatable`, () => {
+ if (this.config.installedDrivers[d].installType !== INSTALL_TYPE_NPM) {
+ throw new NotUpdatableError();
+ }
+ });
+ const update = await spinWith(this.isJsonOutput, `Checking if driver '${d}' needs an update`, async () => {
+ const update = await this.checkForDriverUpdate(d);
+ if (!(update.safeUpdate || update.unsafeUpdate)) {
+ throw new NoUpdatesAvailableError();
+ }
+ return update;
+ });
+ if (!unsafe && !update.safeUpdate) {
+ throw new Error(`Driver '${d}' has a major revision update ` +
+ `(${update.current} => ${update.unsafeUpdate}), which could include ` +
+ `breaking changes. If you want to apply this update, re-run with --unsafe`);
+ }
+ const updateVer = unsafe && update.unsafeUpdate ? update.unsafeUpdate : update.safeUpdate;
+ await spinWith(
+ this.isJsonOutput,
+ `Updating driver '${d}' from ${update.current} to ${updateVer}`,
+ async () => await this.updateDriver(d, updateVer)
+ );
+ updates[d] = {from: update.current, to: updateVer};
+ } catch (err) {
+ errors[d] = err;
+ }
+ }
+
+ log(this.isJsonOutput, 'Update report:');
+ for (const [d, update] of _.toPairs(updates)) {
+ log(this.isJsonOutput, `- Driver ${d} updated: ${update.from} => ${update.to}`.green);
+ }
+ for (const [d, err] of _.toPairs(errors)) {
+ if (err instanceof NotUpdatableError) {
+ log(this.isJsonOutput, `- Driver '${d}' was not installed via npm, so we could not check ` +
+ `for updates`.yellow);
+ } else if (err instanceof NoUpdatesAvailableError) {
+ log(this.isJsonOutput, `- Driver '${d}' had no updates available`.yellow);
+ } else {
+ // otherwise, make it pop with red!
+ log(this.isJsonOutput, `- Driver ${d} failed to update: ${err}`.red);
+ }
+ }
+
+ return {updates, errors};
+ }
+
+ /**
+ * @typedef PossibleUpdates
+ * @property {string} current - current version
+ * @property {string|null} safeUpdate - version we can safely update to if it exists, or null
+ * @property {string|null} unsafeUpdate - version we can unsafely update to if it exists, or null
+ */
+
+ /**
+ * Given a driver name, figure out what its highest possible version upgrade is, and also the
+ * highest possible safe upgrade.
+ *
+ * @param {string} driver - name of driver
+ * @returns {PossibleUpdates}
+ */
+ async checkForDriverUpdate (driver) {
+ // TODO decide how we want to handle beta versions?
+ // this is a helper method, 'driver' is assumed to already be installed here, and of the npm
+ // install type
+ const {version, pkgName} = this.config.installedDrivers[driver];
+ let unsafeUpdate = await this.npm.getLatestVersion(pkgName);
+ let safeUpdate = await this.npm.getLatestSafeUpgradeVersion(pkgName, version);
+ if (!util.compareVersions(unsafeUpdate, '>', version)) {
+ // the latest version is not greater than the current version, so there's no possible update
+ unsafeUpdate = null;
+ safeUpdate = null;
+ }
+ if (unsafeUpdate && unsafeUpdate === safeUpdate) {
+ // the latest update is the same as the safe update, which means it's not actually unsafe
+ unsafeUpdate = null;
+ }
+ if (safeUpdate && !util.compareVersions(safeUpdate, '>', version)) {
+ // even the safe update is not later than the current, so it is not actually an update
+ safeUpdate = null;
+ }
+ return {current: version, safeUpdate, unsafeUpdate};
+ }
+
+ /**
+ * Actually update a driver installed by NPM, using the NPM cli. And update the installation
+ * manifest.
+ *
+ * @param {string} driver - name of driver to update
+ * @param {string} version - version string identifier to update driver to
+ */
+ async updateDriver (driver, version) {
+ const {pkgName} = this.config.installedDrivers[driver];
+ await this.installViaNpm({driver, pkgName, pkgVer: version});
+ this.config.installedDrivers[driver].version = version;
+ await this.config.write();
}
}
export {
- runDriverCommand
+ runDriverCommand,
+ DriverCommand
};

MODIFY
lib/cli/npm.js
lib/cli/npm.js
import _ from 'lodash';
import path from 'path';
+import semver from 'semver';
import { exec } from 'teen_process';
import { mkdirp, util } from 'appium-support';
@@ -54,6 +55,58 @@ export default class NPM {
})).json.latest;
}
+ async getLatestSafeUpgradeVersion (pkg, curVersion) {
+ const allVersions = (await this.exec('view', [pkg, 'versions'], {
+ json: true
+ })).json;
+ return this.getLatestSafeUpgradeFromVersions(curVersion, allVersions);
+ }
+
+ /**
+ * Given a current version and a list of all versions for a package, return the version which is
+ * the highest safely-upgradable version (meaning not crossing any major revision boundaries, and
+ * not including any alpha/beta/rc versions)
+ *
+ * @param {string} curVersion - the current version of a package
+ * @param {Array<string>} allVersions - a list of version strings
+ *
+ * @return {string|null} - the highest safely-upgradable version, or null if there isn't one
+ */
+ getLatestSafeUpgradeFromVersions (curVersion, allVersions) {
+ let safeUpgradeVer = null;
+ const curSemver = semver.parse(curVersion);
+ if (curSemver === null) {
+ throw new Error(`Could not parse current version '${curVersion}'`);
+ }
+ for (const testVer of allVersions) {
+ const testSemver = semver.parse(testVer);
+ if (testSemver === null) {
+ throw new Error(`Could not parse version to test against: '${testVer}'`);
+ }
+ // if the test version is a prerelease, ignore it
+ if (testSemver.prerelease.length > 0) {
+ continue;
+ }
+ // if the current version is later than the test version, skip this test version
+ if (curSemver.compare(testSemver) === 1) {
+ continue;
+ }
+ // if the test version is newer, but crosses a major revision boundary, also skip it
+ if (testSemver.major > curSemver.major) {
+ continue;
+ }
+ // otherwise this version is safe to upgrade to. But there might be multiple ones of this
+ // kind, so keep iterating and keeping the highest
+ if (safeUpgradeVer === null || testSemver.compare(safeUpgradeVer) === 1) {
+ safeUpgradeVer = testSemver;
+ }
+ }
+ if (safeUpgradeVer) {
+ safeUpgradeVer = safeUpgradeVer.format();
+ }
+ return safeUpgradeVer;
+ }
+
async installPackage (pkgDir, pkg, ver) {
const res = await this.exec('install', [
'--no-save',

ADD
test/cli-specs.js
+// transpile:mocha
+
+import chai from 'chai';
+import chaiAsPromised from 'chai-as-promised';
+import DriverConfig, { DEFAULT_APPIUM_HOME } from '../lib/driver-config';
+import { DriverCommand } from '../lib/cli/driver';
+import sinon from 'sinon';
+
+chai.should();
+chai.use(chaiAsPromised);
+
+describe('DriverCommand', function () {
+ const config = new DriverConfig(DEFAULT_APPIUM_HOME);
+ const driver = 'fake';
+ const pkgName = 'appium-fake-driver';
+ config.installedDrivers = {[driver]: {version: '1.0.0', pkgName}};
+ const dc = new DriverCommand({config, json: true});
+
+ describe('#checkForDriverUpdate', function () {
+ let npmMock;
+
+ beforeEach(function () {
+ npmMock = sinon.mock(dc.npm);
+ });
+
+ function setupDriverUpdate (curVersion, latestVersion, latestSafeVersion) {
+ npmMock.expects('getLatestVersion')
+ .once()
+ .withExactArgs(pkgName)
+ .returns(latestVersion);
+ npmMock.expects('getLatestSafeUpgradeVersion')
+ .once()
+ .withExactArgs(pkgName, curVersion)
+ .returns(latestSafeVersion);
+ }
+
+ it('should not return an unsafe update if it is same as safe update', async function () {
+ setupDriverUpdate('1.0.0', '1.1.0', '1.1.0');
+ await dc.checkForDriverUpdate('fake').should.eventually.eql({
+ current: '1.0.0',
+ safeUpdate: '1.1.0',
+ unsafeUpdate: null,
+ });
+ npmMock.verify();
+ });
+
+ it('should not return a safe update if there is not one', async function () {
+ setupDriverUpdate('1.0.0', '2.0.0', null);
+ await dc.checkForDriverUpdate('fake').should.eventually.eql({
+ current: '1.0.0',
+ safeUpdate: null,
+ unsafeUpdate: '2.0.0',
+ });
+ npmMock.verify();
+ });
+
+ it('should return both safe and unsafe update', async function () {
+ setupDriverUpdate('1.0.0', '2.0.0', '1.5.3');
+ await dc.checkForDriverUpdate('fake').should.eventually.eql({
+ current: '1.0.0',
+ safeUpdate: '1.5.3',
+ unsafeUpdate: '2.0.0',
+ });
+ npmMock.verify();
+ });
+ });
+});

ADD
test/npm-specs.js
+// transpile:mocha
+
+import chai from 'chai';
+import NPM from '../lib/cli/npm';
+chai.should();
+
+describe('getLatestSafeUpgradeFromVersions', function () {
+ const versions1 = ['0.1.0', '0.1.1', '0.2.0', '0.2.5', '1.0.0', '1.0.1', '1.1.5', '1.2.7',
+ '2.0.0', '1.2.8-beta', '1.2.9-alpha', '1.3.0-rc', '2.0.1-beta'];
+ const npm = new NPM(null);
+ it('should get the latest minor upgrade in a list of versions', function () {
+ npm.getLatestSafeUpgradeFromVersions('0.1.0', versions1).should.eql('0.2.5');
+ npm.getLatestSafeUpgradeFromVersions('1.0.0', versions1).should.eql('1.2.7');
+ npm.getLatestSafeUpgradeFromVersions('0.2.0', versions1).should.eql('0.2.5');
+ });
+});

feat: add driver update cli command