feat(appium): appium now expects extensions to use peer dependencies

The peer dependency value is stored in the `extensions.yaml` manifest for each extension as `appiumVersion` (if present).

If the peer dependency is _not_ present, the user will be warned, but Appium will still try to load the extension (I have a feeling that most of the time, this will be fine).  When warned, the user will receive information about available extension upgrades, if any.

This required some refactors in the `lib/extension/` dir.  Extension validation was previously a synchronous process, but because we now (potentially) display information to the user about upgrades (which is async), validation must also be async.  This means that the `ExtensionConfig` constructor (and the constructors of its subclasses) cannot run validation.  Validation must happen after the config object is instantiated, which is handled in `loadExtensions()` of `lib/extension/index.js`.  The constructor signatures have changed accordingly.

While extensions now soft-require a `peerDependencies.appium` field, in the monorepo, the value of this dependency is `file:../appium`.  This is treated as a special case, and acts as if the dependency is just the current version of `appium` (as in its `package.json`).  This is handled in `Manifest#addExtension()`.

To further support this, `appium` now exports `@appium/base-driver` as `driver`, `@appium/base-plugin` as `plugin`, and `@appium/support` as `support`.  `tsconfig.json` files have changed in these affected packages.

In addition, a new reusable TS config file has been added for use with basic plugins.

# Conflicts:
#	.eslintrc
#	packages/appium/lib/extension/extension-config.js
#	packages/appium/lib/extension/index.js
#	packages/appium/lib/extension/manifest.js
#	packages/appium/test/unit/extension/manifest.spec.js
#	packages/appium/test/unit/extension/mocks.js
This commit is contained in:
Christopher Hiller
2022-05-03 15:14:44 -07:00
parent 5835a8ab7e
commit 48f1d99087
29 changed files with 645 additions and 123 deletions
+11 -1
View File
@@ -11,7 +11,17 @@
"rules": {"func-names": "off"}
},
{
"files": ["./packages/*/index.js", "./packages/*/scripts/**/*.js", "./test/*.js"],
"files": ["packages/*/index.js", "packages/*/scripts/**/*.js", "test/*.js"],
"parserOptions": {
"sourceType": "script"
}
},
{
"files": [
"packages/appium/support.js",
"packages/appium/driver.js",
"packages/appium/plugin.js"
],
"parserOptions": {
"sourceType": "script"
}
+2 -7
View File
@@ -2,6 +2,7 @@
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"composite": true,
"declaration": true,
"declarationMap": true,
@@ -12,12 +13,6 @@
"strictNullChecks": true,
"removeComments": false,
"target": "es2015",
"types": [
"node",
"mocha",
"chai",
"sinon-chai",
"chai-as-promised"
]
"types": ["node", "mocha", "chai", "sinon-chai", "chai-as-promised"]
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"paths": {
"appium": ["../packages/appium"]
},
"types": ["webdriverio/async"]
},
"extends": "./tsconfig.base.json",
"references": [{"path": "../packages/appium"}]
}
+1
View File
@@ -0,0 +1 @@
export * from '@appium/base-driver';
+14
View File
@@ -0,0 +1,14 @@
'use strict';
// @ts-check
/**
* This module is here to re-export `@appium/base-driver` for Appium extensions.
*
* @see https://npm.im/@appium/base-driver
* @example
* const { BaseDriver, errors } = require('appium/driver');
*/
/** @type {import('@appium/base-driver')} */
module.exports = require('@appium/base-driver');
+3 -3
View File
@@ -84,6 +84,8 @@ class AppiumDriver extends DriverCore {
/** @type {AppiumServer} */
server;
desiredCapConstraints = desiredCapabilityConstraints;
/**
* @param {DriverOpts} opts
*/
@@ -98,8 +100,6 @@ class AppiumDriver extends DriverCore {
super(opts);
this.desiredCapConstraints = desiredCapabilityConstraints;
this.args = {...opts};
// allow this to happen in the background, so no `await`
@@ -406,7 +406,7 @@ class AppiumDriver extends DriverCore {
/**
*
* @param {import('appium/types').DriverClass} InnerDriver
* @param {import('@appium/base-driver').DriverClass} InnerDriver
* @returns {Promise<DriverData[]>}}
*/
// eslint-disable-next-line require-await
+51 -15
View File
@@ -324,27 +324,63 @@ class ExtensionCommand {
* load as the main driver class, or to be able to detect incompatibilities between driver and
* appium versions.
*
* @param {ExtPackageJson<ExtType>} pkgJsonData - the package.json data for a driver module, as if it had been straightforwardly 'require'd
* @param {string} installSpec
* @param {ExtPackageJson<ExtType>} pkgJson - the package.json data for a driver module, as if it had been straightforwardly 'require'd
* @param {string} installSpec - Extension name/spec
* @returns {ExtensionFields<ExtType>}
*/
getExtensionFields(pkgJsonData, installSpec) {
if (!pkgJsonData.appium) {
throw new Error(
`Installed driver did not have an 'appium' section in its ` +
`package.json file as expected`
);
}
const {appium, name, version} = pkgJsonData;
this.validateExtensionFields(appium, installSpec);
getExtensionFields(pkgJson, installSpec) {
this.validatePackageJson(pkgJson, installSpec);
const {appium, name, version, peerDependencies} = pkgJson;
/** @type {unknown} */
const result = {...appium, pkgName: name, version};
const result = {
...appium,
pkgName: name,
version,
appiumVersion: peerDependencies?.appium,
};
return /** @type {ExtensionFields<ExtType>} */ (result);
}
/**
* For any package.json fields which a particular type of extension requires, validate the
* presence and form of those fields on the package.json data, throwing an error if anything is
* Validates the _required_ root fields of an extension's `package.json` file.
*
* These required fields are:
* - `name`
* - `version`
* - `appium`
* @param {import('type-fest').PackageJson} pkgJson - `package.json` of extension
* @param {string} installSpec - Extension name/spec
* @throws {ReferenceError} If `package.json` has a missing or invalid field
* @returns {pkgJson is ExtPackageJson<ExtType>}
*/
validatePackageJson(pkgJson, installSpec) {
const {appium, name, version} = /** @type {ExtPackageJson<ExtType>} */ (pkgJson);
/**
*
* @param {string} field
* @returns {ReferenceError}
*/
const createMissingFieldError = (field) =>
new ReferenceError(
`${this.type} "${installSpec}" invalid; missing a \`${field}\` field of its \`package.json\``
);
if (!name) {
throw createMissingFieldError('name');
}
if (!version) {
throw createMissingFieldError('version');
}
if (!appium) {
throw createMissingFieldError('appium');
}
this.validateExtensionFields(appium, installSpec);
return true;
}
/**
* For any `package.json` fields which a particular type of extension requires, validate the
@@ -728,7 +764,7 @@ export {ExtensionCommand};
/**
* Returned by {@linkcode ExtensionCommand.getExtensionFields}
* @template {ExtensionType} ExtType
* @typedef {ExtMetadata<ExtType> & { pkgName: string, version: string } & import('../../types/external-manifest').CommonMetadata} ExtensionFields
* @typedef {ExtMetadata<ExtType> & { pkgName: string, version: string, appiumVersion: string } & import('appium/types').CommonMetadata} ExtensionFields
*/
/**
+1 -1
View File
@@ -5,7 +5,7 @@ import PluginCommand from './plugin-command';
import {DRIVER_TYPE, PLUGIN_TYPE} from '../constants';
import {errAndQuit, log, JSON_SPACES} from './utils';
const commandClasses = Object.freeze(
export const commandClasses = Object.freeze(
/** @type {const} */ ({
[DRIVER_TYPE]: DriverCommand,
[PLUGIN_TYPE]: PluginCommand,
+6 -12
View File
@@ -31,14 +31,10 @@ export class DriverConfig extends ExtensionConfig {
* @param {import('./manifest').Manifest} manifest - Manifest instance
* @param {DriverConfigOptions} [opts]
*/
constructor(manifest, {logFn, extData} = {}) {
constructor(manifest, {logFn} = {}) {
super(DRIVER_TYPE, manifest, logFn);
this.knownAutomationNames = new Set();
if (extData) {
this.validate(extData);
}
}
/**
@@ -49,8 +45,8 @@ export class DriverConfig extends ExtensionConfig {
* @throws If `manifest` already associated with a `DriverConfig`
* @returns {DriverConfig}
*/
static create(manifest, {extData, logFn} = {}) {
const instance = new DriverConfig(manifest, {logFn, extData});
static create(manifest, {logFn} = {}) {
const instance = new DriverConfig(manifest, {logFn});
if (DriverConfig.getInstance(manifest)) {
throw new Error(
`Manifest with APPIUM_HOME ${manifest.appiumHome} already has a DriverConfig; use DriverConfig.getInstance() to retrieve it.`
@@ -71,11 +67,10 @@ export class DriverConfig extends ExtensionConfig {
/**
* Checks extensions for problems
* @param {ExtRecord<DriverType>} exts
*/
validate(exts) {
async validate() {
this.knownAutomationNames.clear();
return super.validate(exts);
return await super._validate(this.manifest.getExtensionData(DRIVER_TYPE));
}
/**
@@ -220,7 +215,6 @@ export class DriverConfig extends ExtensionConfig {
/**
* @typedef DriverConfigOptions
* @property {import('./extension-config').ExtensionLogFn} [logFn] - Optional logging function
* @property {ManifestData['drivers']} [extData] - Extension data
*/
/**
@@ -252,7 +246,7 @@ export class DriverConfig extends ExtensionConfig {
/**
* Return value of {@linkcode DriverConfig.findMatchingDriver}
* @typedef MatchedDriver
* @property {import('appium/types').DriverClass} driver
* @property {import('@appium/base-driver').DriverClass} driver
* @property {string} version
* @property {string} driverName
*/
+109 -24
View File
@@ -1,6 +1,9 @@
import _ from 'lodash';
import path from 'path';
import resolveFrom from 'resolve-from';
import {satisfies} from 'semver';
import {commandClasses} from '../cli/extension';
import {APPIUM_VER} from '../config';
import log from '../logger';
import {
ALLOWED_SCHEMA_EXTENSIONS,
@@ -43,6 +46,11 @@ export class ExtensionConfig {
/** @type {Manifest} */
manifest;
/**
* @type {import('../cli/extension-command').ExtensionListData}
*/
_listDataCache;
/**
* @protected
* @param {ExtType} extensionType - Type of extension
@@ -70,11 +78,13 @@ export class ExtensionConfig {
* Checks extensions for problems
* @param {ExtRecord<ExtType>} exts - Extension data
*/
validate(exts) {
async _validate(exts) {
const foundProblems = /** @type {Record<ExtName<ExtType>,Problem[]>} */ ({});
for (const [extName, extData] of /** @type {[ExtName<ExtType>, ExtManifest<ExtType>][]} */ (
_.toPairs(exts)
)) {
await this.displayConfigWarnings(extData, extName);
foundProblems[extName] = [
...this.getGenericConfigProblems(extData, extName),
...this.getConfigProblems(extData),
@@ -82,6 +92,7 @@ export class ExtensionConfig {
];
}
/** @type {string[]} */
const problemSummaries = [];
for (const [extName, problems] of _.toPairs(foundProblems)) {
if (_.isEmpty(problems)) {
@@ -101,8 +112,7 @@ export class ExtensionConfig {
if (!_.isEmpty(problemSummaries)) {
this.log(
`Appium encountered one or more errors while validating ` +
`the ${this.configKey} extension file (${this.manifestPath}):`
`Appium encountered one or more unrecoverable errors while validating ${this.configKey} found in manifest ${this.manifestPath}`
);
for (const summary of problemSummaries) {
this.log(summary);
@@ -113,11 +123,96 @@ export class ExtensionConfig {
}
/**
* Retrieves listing data for extensions via command class.
* Caches the result in {@linkcode ExtensionConfig._listDataCache}
* @protected
* @returns {import('../cli/extension-command').ExtensionListData}
*/
async getListData() {
if (this._listDataCache) {
return this._listDataCache;
}
const CommandClass = /** @type {import('../cli/extension').ExtCommand<ExtType>} */ (
commandClasses[this.extensionType]
);
const cmd = new CommandClass({config: this, json: true});
const listData = await cmd.list({showInstalled: true, showUpdates: true});
this._listDataCache = listData;
return listData;
}
/**
* Displays non-"fatal" warnings for the extension
*
* This method uses Appium's logger, since the default extension logger uses the `error` log level.
*
* @param {ExtManifest<ExtType>} extData
* @param {ExtName<ExtType>} extName
* @returns {Promise<void>}
*/
async displayConfigWarnings(extData, extName) {
const {appiumVersion, installSpec, installType, pkgName} = extData;
if (!_.isString(installSpec)) {
log.warn(
`${_.capitalize(
this.extensionType
)} "${extName}" (package \`${pkgName}\`) has an invalid or missing \`installSpec\` property in \`extensions.yaml\`; this may cause upgrades done via the \`appium\` CLI to fail.`
);
}
if (!INSTALL_TYPES.has(installType)) {
log.warn(
`${_.capitalize(
this.extensionType
)} "${extName}" (package \`${pkgName}\`) has an invalid or missing \`installType\` property in \`extensions.yaml\`; this may cause upgrades done via the \`appium\` CLI to fail.`
);
extData.installType = INSTALL_TYPE_NPM;
}
if (_.isString(appiumVersion) && !satisfies(APPIUM_VER, appiumVersion)) {
const listData = await this.getListData();
if (listData[extName]) {
const extListData =
/** @type {import('../cli/extension-command').InstalledExtensionListData} */ (
listData[extName]
);
const {unsafeUpdateVersion, updateVersion, upToDate} = extListData;
if (!upToDate) {
const upgradeText =
unsafeUpdateVersion === updateVersion
? `v${updateVersion}`
: `v${updateVersion} or (potentially unsafe) v${unsafeUpdateVersion}`;
log.warn(
`${_.capitalize(
this.extensionType
)} "${extName}" (package \`${pkgName}\`) may be incompatible with the current version of Appium (v${APPIUM_VER}) due to its peer dependency on older Appium v${appiumVersion}. Please upgrade \`${pkgName}\` to ${upgradeText}.`
);
} else {
log.warn(
`${_.capitalize(
this.extensionType
)} "${extName}" (package \`${pkgName}\`) may be incompatible with the current version of Appium (v${APPIUM_VER}) due to its peer dependency on older Appium v${appiumVersion}. Please ask the developer of \`${pkgName}\` to update the peer dependency on Appium to ${APPIUM_VER}.`
);
}
}
} else if (!_.isString(appiumVersion)) {
log.warn(
`${_.capitalize(
this.extensionType
)} "${extName}" (package \`${pkgName}\`) may be incompatible with the current version of Appium (v${APPIUM_VER}) due to an invalid or missing peer dependency on Appium. Please ask the developer of \`${pkgName}\` to add a peer dependency on \`appium@${APPIUM_VER}\`.`
);
}
}
/**
* Returns list of unrecoverable errors (if any) for the given extension _if_ it has a `schema` property.
*
* @param {ExtManifest<ExtType>} extData - Extension data (from manifest)
* @param {ExtName<ExtType>} extName - Extension name (from manifest)
* @returns {Problem[]}
*/
getSchemaProblems(extData, extName) {
/** @type {Problem[]} */
const problems = [];
const {schema: argSchemaPath} = extData;
if (ExtensionConfig.extDataHasSchema(extData)) {
@@ -159,43 +254,33 @@ export class ExtensionConfig {
}
/**
* @param {ExtManifest<ExtType>} extData
* @param {ExtName<ExtType>} extName
* Return a list of generic unrecoverable errors for the given extension
* @param {ExtManifest<ExtType>} extData - Extension data (from manifest)
* @param {ExtName<ExtType>} extName - Extension name (from manifest)
* @returns {Problem[]}
*/
// eslint-disable-next-line no-unused-vars
getGenericConfigProblems(extData, extName) {
const {version, pkgName, installSpec, installType, mainClass} = extData;
const {version, pkgName, mainClass} = extData;
const problems = [];
if (!_.isString(version)) {
problems.push({err: 'Missing or incorrect version', val: version});
problems.push({
err: `Invalid or missing \`version\` field in my \`package.json\` and/or \`extensions.yaml\` (must be a string)`,
val: version,
});
}
if (!_.isString(pkgName)) {
problems.push({
err: 'Missing or incorrect NPM package name',
err: `Invalid or missing \`name\` field in my \`package.json\` and/or \`extensions.yaml\` (must be a string)`,
val: pkgName,
});
}
if (!_.isString(installSpec)) {
problems.push({
err: 'Missing or incorrect installation spec',
val: installSpec,
});
}
if (!INSTALL_TYPES.has(installType)) {
problems.push({
err: 'Missing or incorrect install type',
val: installType,
});
}
if (!_.isString(mainClass)) {
problems.push({
err: 'Missing or incorrect driver class name',
err: `Invalid or missing \`appium.mainClass\` field in my \`package.json\` and/or \`mainClass\` field in \`extensions.yaml\` (must be a string)`,
val: mainClass,
});
}
@@ -398,7 +483,7 @@ export {INSTALL_TYPE_NPM, INSTALL_TYPE_GIT, INSTALL_TYPE_LOCAL, INSTALL_TYPE_GIT
*/
/**
* @typedef {import('appium/types').ExtensionType} ExtensionType
* @typedef {import('@appium/types').ExtensionType} ExtensionType
* @typedef {import('./manifest').Manifest} Manifest
*/
+8 -7
View File
@@ -4,6 +4,7 @@ import log from '../logger';
import {DriverConfig} from './driver-config';
import {Manifest} from './manifest';
import {PluginConfig} from './plugin-config';
import B from 'bluebird';
/**
* Loads extensions and creates `ExtensionConfig` instances.
@@ -18,11 +19,11 @@ import {PluginConfig} from './plugin-config';
*/
export async function loadExtensions(appiumHome) {
const manifest = Manifest.getInstance(appiumHome);
const {drivers, plugins} = await manifest.read();
const driverConfig =
DriverConfig.getInstance(manifest) ?? DriverConfig.create(manifest, {extData: drivers});
const pluginConfig =
PluginConfig.getInstance(manifest) ?? PluginConfig.create(manifest, {extData: plugins});
await manifest.read();
const driverConfig = DriverConfig.getInstance(manifest) ?? DriverConfig.create(manifest);
const pluginConfig = PluginConfig.getInstance(manifest) ?? PluginConfig.create(manifest);
await B.all([driverConfig.validate(), pluginConfig.validate()]);
return {driverConfig, pluginConfig};
}
@@ -91,6 +92,6 @@ export function getActiveDrivers(driverConfig, useDrivers = []) {
/**
* @typedef ExtensionConfigs
* @property {DriverConfig} driverConfig
* @property {PluginConfig} pluginConfig
* @property {import('./driver-config').DriverConfig} driverConfig
* @property {import('./plugin-config').PluginConfig} pluginConfig
*/
+13 -3
View File
@@ -10,6 +10,7 @@ import {DRIVER_TYPE, PLUGIN_TYPE} from '../constants';
import log from '../logger';
import {INSTALL_TYPE_NPM} from './extension-config';
import {packageDidChange} from './package-changed';
import {APPIUM_VER} from '../config';
/**
* Default depth to search in directory tree for whatever it is we're looking for.
@@ -226,12 +227,15 @@ export class Manifest {
* @returns {boolean} - `true` upon success, `false` if the extension is already registered.
*/
addExtensionFromPackage(pkgJson, pkgPath) {
const extensionPath = path.dirname(pkgPath);
/**
* @type {InternalMetadata}
*/
const internal = {
pkgName: pkgJson.name,
version: pkgJson.version,
appiumVersion: pkgJson.peerDependencies?.appium,
installType: INSTALL_TYPE_NPM,
installSpec: `${pkgJson.name}@${pkgJson.version}`,
};
@@ -256,7 +260,7 @@ export class Manifest {
return false;
} else {
throw new TypeError(
`The extension in ${path.dirname(pkgPath)} is neither a valid driver nor a valid plugin.`
`The extension in ${extensionPath} is neither a valid driver nor a valid plugin.`
);
}
}
@@ -270,10 +274,15 @@ export class Manifest {
* @param {ExtType} extType - `driver` or `plugin`
* @param {string} extName - Name of extension
* @param {ExtManifest<ExtType>} extData - Extension metadata
* @returns {void}
* @returns {ExtManifest<ExtType>} A clone of `extData`, potentially with a mutated `appiumVersion` field
*/
addExtension(extType, extName, extData) {
this._data[`${extType}s`][extName] = extData;
const data = _.clone(extData);
if (data.appiumVersion?.startsWith('file:..')) {
data.appiumVersion = APPIUM_VER;
}
this._data[`${extType}s`][extName] = data;
return data;
}
/**
@@ -326,6 +335,7 @@ export class Manifest {
log.debug(`Reading ${this._manifestPath}...`);
const yaml = await fs.readFile(this._manifestPath, 'utf8');
data = YAML.parse(yaml);
log.debug(`Parsed manifest file: ${JSON.stringify(data, null, 2)}`);
} catch (err) {
if (err.code === 'ENOENT') {
data = _.cloneDeep(INITIAL_MANIFEST_DATA);
@@ -27,12 +27,12 @@ export class PluginConfig extends ExtensionConfig {
* @param {Manifest} manifest - IO object
* @param {PluginConfigOptions} [opts]
*/
constructor(manifest, {extData, logFn} = {}) {
constructor(manifest, {logFn} = {}) {
super(PLUGIN_TYPE, manifest, logFn);
}
if (extData) {
this.validate(extData);
}
async validate() {
return await super._validate(this.manifest.getExtensionData(PLUGIN_TYPE));
}
/**
@@ -43,8 +43,8 @@ export class PluginConfig extends ExtensionConfig {
* @throws If `manifest` already associated with a `PluginConfig`
* @returns {PluginConfig}
*/
static create(manifest, {extData, logFn} = {}) {
const instance = new PluginConfig(manifest, {logFn, extData});
static create(manifest, {logFn} = {}) {
const instance = new PluginConfig(manifest, {logFn});
if (PluginConfig.getInstance(manifest)) {
throw new Error(
`Manifest with APPIUM_HOME ${manifest.appiumHome} already has a PluginConfig; use PluginConfig.getInstance() to retrieve it.`
@@ -105,7 +105,6 @@ export class PluginConfig extends ExtensionConfig {
/**
* @typedef PluginConfigOptions
* @property {import('./extension-config').ExtensionLogFn} [logFn] - Optional logging function
* @property {import('appium/types').PluginRecord} [extData] - Extension data
*/
/**
+4 -4
View File
@@ -384,9 +384,9 @@ export {finalizeSchema, getSchema, validate} from './schema/schema';
export {main, init, resolveAppiumHome};
/**
* @typedef {import('appium/types').DriverType} DriverType
* @typedef {import('appium/types').PluginType} PluginType
* @typedef {import('appium/types').DriverClass} DriverClass
* @typedef {import('@appium/types').DriverType} DriverType
* @typedef {import('@appium/types').PluginType} PluginType
* @typedef {import('@appium/base-driver').DriverClass} DriverClass
* @typedef {import('appium/types').PluginClass} PluginClass
* @typedef {import('appium/types').WithServerSubcommand} WithServerSubcommand
*/
@@ -398,7 +398,7 @@ export {main, init, resolveAppiumHome};
/**
* @typedef ServerInitData
* @property {AppiumDriver} appiumDriver - The Appium driver
* @property {import('./appium').AppiumDriver} appiumDriver - The Appium driver
* @property {import('appium/types').ParsedArgs} parsedArgs - The parsed arguments
*/
+5
View File
@@ -33,6 +33,10 @@
"lib",
"build",
"index.js",
"driver.*",
"support.*",
"plugin.*",
"test.*",
"scripts/postinstall.js",
"types"
],
@@ -58,6 +62,7 @@
"@appium/docutils": "file:../docutils",
"@appium/schema": "file:../schema",
"@appium/support": "file:../support",
"@appium/test-support": "file:../test-support",
"@babel/runtime": "7.17.9",
"@sidvind/better-ajv-errors": "2.0.0",
"ajv": "8.11.0",
+1
View File
@@ -0,0 +1 @@
export * from '@appium/base-plugin';
+13
View File
@@ -0,0 +1,13 @@
'use strict';
// @ts-check
/**
* This module is here to re-export `@appium/base-plugin` for Appium extensions.
*
* @see https://npm.im/@appium/base-plugin
* @example
* const { BasePlugin } = require('appium/plugin');
*/
module.exports = require('@appium/base-plugin');
+1
View File
@@ -0,0 +1 @@
export * from '@appium/support';
+13
View File
@@ -0,0 +1,13 @@
'use strict';
// @ts-check
/**
* This module is here to re-export `@appium/support` for Appium extensions.
*
* @see https://npm.im/@appium/support
* @example
* const { fs, npm } = require('appium/support');
*/
module.exports = require('@appium/support');
+1 -1
View File
@@ -40,7 +40,7 @@ describe('CLI behavior', function () {
this.timeout(30000);
});
describe('when appium is a dependency', function () {
describe('when appium is a dependency of the project in the current working directory', function () {
/** @type {string} */
let hashPath;
/** @type {string} */
+1 -1
View File
@@ -44,7 +44,7 @@ describe('FakeDriver - via HTTP', function () {
let appiumHome;
// since we update the FakeDriver.prototype below, make sure we update the FakeDriver which is
// actually going to be required by Appium
/** @type {import('appium/types').DriverClass} */
/** @type {import('@appium/types').DriverClass} */
let FakeDriver;
/** @type {string} */
let testServerBaseSessionUrl;
@@ -1,11 +1,211 @@
// @ts-check
import {DRIVER_TYPE} from '../../../lib/constants';
import {version as APPIUM_VER} from '../../../package.json';
import {rewiremock} from '../../helpers';
import {initMocks} from './mocks';
const {expect} = chai;
describe('ExtensionConfig', function () {
describe('getGenericConfigProblems()', function () {
it('should have some tests');
let sandbox;
/** @type {typeof import('appium/lib/extension/extension-config').ExtensionConfig} */
let ExtensionConfig;
/** @type {typeof import('appium/lib/extension/manifest').Manifest} */
let Manifest;
/** @type {import('./mocks').MockAppiumSupport} */
let MockAppiumSupport;
beforeEach(function () {
let overrides;
({MockAppiumSupport, overrides, sandbox} = initMocks());
({ExtensionConfig} = rewiremock.proxy(
() => require('../../../lib/extension/extension-config'),
overrides
));
({Manifest} = rewiremock.proxy(() => require('../../../lib/extension/manifest'), overrides));
MockAppiumSupport.fs.readPackageJsonFrom.returns({version: '2.0.0'});
});
describe('validate()', function () {
it('should have some tests');
afterEach(function () {
sandbox.restore();
});
describe('constructor', function () {});
describe('instance method', function () {
/** @type {import('appium/lib/extension/extension-config').ExtensionConfig<DriverType>} */
let config;
let extData;
beforeEach(function () {
config = new ExtensionConfig(DRIVER_TYPE, new Manifest('/some/path'));
extData = {
version: '1.0.0',
automationName: 'Derp',
mainClass: 'SomeClass',
pkgName: 'derp',
platformNames: ['dogs', 'cats'],
installSpec: 'derp',
installType: 'npm',
appiumVersion: APPIUM_VER,
};
config.addExtension('derp', extData);
});
describe('getGenericConfigProblems()', function () {
describe('when there are no problems with the extension data', function () {
it('should return an empty array', function () {
expect(config.getGenericConfigProblems(extData, 'derp')).to.be.empty;
});
});
describe('when the extension data is missing a "pkgName" field', function () {
beforeEach(function () {
delete extData.pkgName;
});
it('should return a problem', function () {
expect(config.getGenericConfigProblems(extData, 'derp')).to.eql([
{
err: 'Invalid or missing `name` field in my `package.json` and/or `extensions.yaml` (must be a string)',
val: undefined,
},
]);
});
});
describe('when the extension data is missing a "version" field', function () {
beforeEach(function () {
delete extData.version;
});
it('should return a problem', function () {
expect(config.getGenericConfigProblems(extData, 'derp')).to.eql([
{
err: 'Invalid or missing `version` field in my `package.json` and/or `extensions.yaml` (must be a string)',
val: undefined,
},
]);
});
});
describe('when the extension data is missing a "appium.mainClass" field', function () {
beforeEach(function () {
delete extData.mainClass;
});
it('should return a problem', function () {
expect(config.getGenericConfigProblems(extData, 'derp')).to.eql([
{
err: 'Invalid or missing `appium.mainClass` field in my `package.json` and/or `mainClass` field in `extensions.yaml` (must be a string)',
val: undefined,
},
]);
});
});
});
describe('displayConfigWarnings()', function () {
/** @type {ExtManifest<DriverType>} */
const extData = {
version: '1.0.0',
automationName: 'Derp',
mainClass: 'SomeClass',
pkgName: 'derp',
platformNames: ['dogs', 'cats'],
installSpec: 'derp',
installType: 'npm',
appiumVersion: APPIUM_VER,
};
/**
* @type {ExtensionConfig<DriverType>}
*/
let config;
beforeEach(function () {
const manifest = Manifest.getInstance('/some/path');
manifest.addExtension(DRIVER_TYPE, 'derp', extData);
config = new ExtensionConfig(DRIVER_TYPE, manifest);
});
describe('when the extension data is missing an `installSpec` field', function () {
beforeEach(function () {
delete extData.installSpec;
});
it('should log a warning', async function () {
await config.displayConfigWarnings(extData, 'derp');
expect(MockAppiumSupport.logger.getLogger().warn).to.have.been.calledWith(
'Driver "derp" (package `derp`) has an invalid or missing `installSpec` property in `extensions.yaml`; this may cause upgrades done via the `appium` CLI to fail.'
);
});
});
describe('when the extension data is missing an `installType` field', function () {
beforeEach(function () {
delete extData.installType;
});
it('should log a warning', async function () {
await config.displayConfigWarnings(extData, 'derp');
expect(MockAppiumSupport.logger.getLogger().warn).to.have.been.calledWith(
'Driver "derp" (package `derp`) has an invalid or missing `installType` property in `extensions.yaml`; this may cause upgrades done via the `appium` CLI to fail.'
);
});
});
describe('when the extension data is missing an `appiumVersion` field', function () {
beforeEach(function () {
delete extData.appiumVersion;
});
it('should log a warning', async function () {
await config.displayConfigWarnings(extData, 'derp');
expect(MockAppiumSupport.logger.getLogger().warn).to.have.been.calledWith(
`Driver "derp" (package \`derp\`) may be incompatible with the current version of Appium (v${APPIUM_VER}) due to an invalid or missing peer dependency on Appium. Please ask the developer of \`derp\` to add a peer dependency on \`appium@${APPIUM_VER}\`.`
);
});
});
describe('when the extension data has an `appiumVersion` field which does not satisfy the current version of Appium, and an upgrade is available', function () {
beforeEach(function () {
extData.appiumVersion = '1.9.9';
});
it('should log a warning', async function () {
await config.displayConfigWarnings(extData, 'derp');
expect(MockAppiumSupport.logger.getLogger().warn).to.have.been.calledWith(
`Driver "derp" (package \`derp\`) may be incompatible with the current version of Appium (v${APPIUM_VER}) due to its peer dependency on older Appium v${extData.appiumVersion}. Please upgrade \`derp\` to v1.1.0 or (potentially unsafe) v2.0.0.`
);
});
});
describe('when the extension data has an `appiumVersion` field which does not satisfy the current version of Appium, and no upgrade is available', function () {
beforeEach(function () {
extData.appiumVersion = '1.9.9';
MockAppiumSupport.util.compareVersions.returns(false);
MockAppiumSupport.npm.getLatestSafeUpgradeVersion.resolves('1.0.0');
MockAppiumSupport.npm.getLatestVersion.resolves('1.0.0');
});
it('should log a warning', async function () {
await config.displayConfigWarnings(extData, 'derp');
expect(MockAppiumSupport.logger.getLogger().warn).to.have.been.calledWith(
`Driver "derp" (package \`derp\`) may be incompatible with the current version of Appium (v${APPIUM_VER}) due to its peer dependency on older Appium v${extData.appiumVersion}. Please ask the developer of \`derp\` to update the peer dependency on Appium to ${APPIUM_VER}.`
);
});
});
});
describe('validate()', function () {
it('should have some tests');
});
});
});
/**
* @typedef {import('appium/types').DriverType} DriverType
*/
@@ -4,6 +4,7 @@ import {promises as fs} from 'fs';
import {DRIVER_TYPE, PLUGIN_TYPE} from '../../../lib/constants';
import {resolveFixture, rewiremock} from '../../helpers';
import {initMocks} from './mocks';
import {version as APPIUM_VER} from '../../../package.json';
const {expect} = chai;
@@ -35,7 +36,6 @@ describe('Manifest', function () {
let overrides;
({MockPackageChanged, MockAppiumSupport, overrides, sandbox} = initMocks());
MockAppiumSupport.fs.readFile.resolves(yamlFixture);
({Manifest} = rewiremock.proxy(() => require('../../../lib/extension/manifest'), overrides));
Manifest.getInstance.cache = new Map();
@@ -240,6 +240,7 @@ describe('Manifest', function () {
platformNames: ['dogs', 'cats'],
installSpec: 'derp',
installType: 'npm',
appiumVersion: '2.0.0',
};
beforeEach(async function () {
@@ -298,11 +299,14 @@ describe('Manifest', function () {
platformNames: ['dogs', 'cats'],
installSpec: 'derp',
installType: 'npm',
appiumVersion: '2.0.0',
};
it('should add the extension to the internal data object', function () {
manifest.addExtension('driver', 'foo', extData);
expect(manifest.getExtensionData('driver').foo).to.equal(extData);
it('should add a clone of the extension manifest to the internal data object', function () {
manifest.addExtension(DRIVER_TYPE, 'foo', extData);
expect(manifest.getExtensionData(DRIVER_TYPE).foo)
.to.eql(extData)
.and.not.to.equal(extData);
});
describe('when existing extension added', function () {
@@ -317,12 +321,55 @@ describe('Manifest', function () {
...extData,
automationName: 'BLAAHAH',
};
manifest.addExtension('driver', 'foo', expected);
expect(manifest.getExtensionData('driver').foo).to.equal(expected);
manifest.addExtension(DRIVER_TYPE, 'foo', expected);
expect(manifest.getExtensionData(DRIVER_TYPE).foo).to.eql(expected);
});
});
describe('when the extension has no peer dependency on `appium`', function () {
beforeEach(function () {
delete extData.appiumVersion;
});
it('should work anyway', function () {
manifest.addExtension(DRIVER_TYPE, 'foo', extData);
expect(manifest.getExtensionData(DRIVER_TYPE).foo).to.not.have.property('appiumVersion');
});
});
describe('when the extension has a peer dependency on `appium`, but it references a filepath', function () {
beforeEach(function () {
extData.appiumVersion = 'file:../appium';
});
it('should set `appiumVersion` to the current appium version', function () {
manifest.addExtension(DRIVER_TYPE, 'foo', extData);
expect(manifest.getExtensionData(DRIVER_TYPE).foo.appiumVersion).to.equal(APPIUM_VER);
});
});
});
describe('getExtensionData()', function () {
/** @type {ExtManifest<DriverType>} */
const extData = {
version: '1.0.0',
automationName: 'Derp',
mainClass: 'SomeClass',
pkgName: 'derp',
platformNames: ['dogs', 'cats'],
installSpec: 'derp',
installType: 'npm',
appiumVersion: '2.0.0',
};
beforeEach(function () {
manifest.addExtension(DRIVER_TYPE, 'foo', extData);
});
it('should return all extension data for an extension type', function () {
expect(manifest.getExtensionData(DRIVER_TYPE)).to.eql({foo: extData});
});
});
describe('addExtensionFromPackage()', function () {
describe('when provided a valid package.json for a driver and its path', function () {
/** @type {ExtPackageJson<DriverType>} */
@@ -338,12 +385,15 @@ describe('Manifest', function () {
platformNames: ['dogs', 'cats'],
driverName: 'myDriver',
},
peerDependencies: {
appium: '2.0.0',
},
};
});
it('should add an extension to the internal data', function () {
manifest.addExtensionFromPackage(packageJson, '/some/path/to/package.json');
expect(manifest.getExtensionData('driver')).to.deep.equal({
expect(manifest.getExtensionData(DRIVER_TYPE)).to.deep.equal({
myDriver: {
automationName: 'derp',
mainClass: 'SomeClass',
@@ -352,6 +402,7 @@ describe('Manifest', function () {
version: '1.0.0',
installType: 'npm',
installSpec: 'derp@1.0.0',
appiumVersion: '2.0.0',
},
});
});
@@ -384,6 +435,9 @@ describe('Manifest', function () {
mainClass: 'SomeClass',
pluginName: 'myPlugin',
},
peerDependencies: {
appium: '2.0.0',
},
};
});
@@ -396,6 +450,7 @@ describe('Manifest', function () {
version: '1.0.0',
installType: 'npm',
installSpec: 'derp@1.0.0',
appiumVersion: '2.0.0',
},
});
});
@@ -428,6 +483,34 @@ describe('Manifest', function () {
).to.throw(/neither a valid driver nor a valid plugin/);
});
});
describe('when the extension has an appium peer dependency beginning with `file:..`', function () {
/** @type {ExtPackageJson<DriverType>} */
let packageJson;
beforeEach(function () {
packageJson = {
name: 'derp',
version: '1.0.0',
appium: {
automationName: 'derp',
mainClass: 'SomeClass',
platformNames: ['dogs', 'cats'],
driverName: 'myDriver',
},
peerDependencies: {
appium: 'file:../appium',
},
};
});
it('should set the appiumVersion to the current Appium version', function () {
manifest.addExtensionFromPackage(packageJson, '/some/path/to/package.json');
expect(manifest.getExtensionData(DRIVER_TYPE).myDriver.appiumVersion).to.equal(
APPIUM_VER
);
});
});
});
describe('syncWithInstalledExtensions()', function () {
@@ -465,6 +548,9 @@ describe('Manifest', function () {
platformNames: ['dogs', 'cats'],
driverName: 'myDriver',
},
peerDependencies: {
appium: '2.0.0',
},
});
});
+51 -4
View File
@@ -7,6 +7,7 @@
import path from 'path';
import {createSandbox} from 'sinon';
import {version as APPIUM_VER} from '../../../package.json';
export function initMocks(sandbox = createSandbox()) {
/**
@@ -25,6 +26,12 @@ export function initMocks(sandbox = createSandbox()) {
})
),
mkdirp: /** @type {MockAppiumSupportFs['mkdirp']} */ (sandbox.stub().resolves()),
readPackageJsonFrom: /** @type {MockAppiumSupportFs['readPackageJsonFrom']} */ (
sandbox.stub().returns({version: APPIUM_VER, engines: {node: '>=12'}})
),
findRoot: /** @type {MockAppiumSupportFs['findRoot']} */ (
sandbox.stub().returns(path.join(__dirname, '..', '..', '..'))
),
},
env: {
resolveAppiumHome: /** @type {MockAppiumSupportEnv['resolveAppiumHome']} */ (
@@ -48,9 +55,27 @@ export function initMocks(sandbox = createSandbox()) {
},
logger: {
getLogger: /** @type {MockAppiumSupportLogger['getLogger']} */ (
sandbox
.stub()
.returns(sandbox.stub(new global.console.Console(process.stdout, process.stderr)))
sandbox.stub().callsFake(() => MockAppiumSupport.logger.__logger)
),
__logger: sandbox.stub(new global.console.Console(process.stdout, process.stderr)),
},
system: {
isWindows: /** @type {MockAppiumSupportSystem['isWindows']} */ (
sandbox.stub().returns(false)
),
},
npm: {
getLatestVersion: /** @type {MockAppiumSupportNpm['getLatestVersion']} */ (
sandbox.stub().resolves('2.0.0')
),
getLatestSafeUpgradeVersion:
/** @type {MockAppiumSupportNpm['getLatestSafeUpgradeVersion']} */ (
sandbox.stub().resolves('1.1.0')
),
},
util: {
compareVersions: /** @type {MockAppiumSupportUtil['compareVersions']} */ (
sandbox.stub().returns(true)
),
},
};
@@ -98,12 +123,16 @@ export function initMocks(sandbox = createSandbox()) {
* @property {MockAppiumSupportLogger} logger
* @property {MockAppiumSupportFs} fs
* @property {MockAppiumSupportEnv} env
* @property {MockAppiumSupportSystem} system
* @property {MockAppiumSupportNpm} npm
* @property {MockAppiumSupportUtil} util
*/
/**
* Mock of package `@appium/support`'s `logger` module
* @typedef MockAppiumSupportLogger
* @property {sinon.SinonStub<[string?], typeof console>} getLogger
* @property {sinon.SinonStub<[string?], Console>} getLogger
* @property {sinon.SinonStubbedInstance<Console>} __logger
*/
/**
@@ -113,6 +142,8 @@ export function initMocks(sandbox = createSandbox()) {
* @property {sinon.SinonStubbedMember<import('@appium/support/lib/fs')['fs']['writeFile']>} writeFile
* @property {sinon.SinonStubbedMember<import('@appium/support/lib/fs')['fs']['walk']>} walk
* @property {sinon.SinonStubbedMember<import('@appium/support/lib/fs')['fs']['mkdirp']>} mkdirp
* @property {sinon.SinonStubbedMember<import('@appium/support/lib/fs')['fs']['readPackageJsonFrom']>} readPackageJsonFrom
* @property {sinon.SinonStubbedMember<import('@appium/support/lib/fs')['fs']['findRoot']>} findRoot
*/
/**
@@ -125,6 +156,22 @@ export function initMocks(sandbox = createSandbox()) {
* @property {import('@appium/support/lib/env').NormalizedPackageJson} __pkg
*/
/**
* @typedef MockAppiumSupportSystem
* @property {sinon.SinonStubbedMember<import('@appium/support/lib/system').isWindows>} isWindows
*/
/**
* @typedef MockAppiumSupportNpm
* @property {sinon.SinonStubbedMember<import('@appium/support/lib/npm').NPM['getLatestVersion']>} getLatestVersion
* @property {sinon.SinonStubbedMember<import('@appium/support/lib/npm').NPM['getLatestSafeUpgradeVersion']>} getLatestSafeUpgradeVersion
*/
/**
* @typedef MockAppiumSupportUtil
* @property {sinon.SinonStubbedMember<import('@appium/support/lib/util').compareVersions>} compareVersions
*/
/**
* Mock of package `package-changed`
* @typedef MockPackageChanged
+4 -2
View File
@@ -6,6 +6,7 @@
"paths": {
"@appium/support": ["../support"],
"@appium/base-driver": ["../base-driver"],
"@appium/base-plugin": ["../base-plugin"],
"@appium/types": ["../types"],
"@appium/schema": ["../schema"],
"appium": ["."]
@@ -15,8 +16,9 @@
"include": ["lib", "types"],
"references": [
{"path": "../support"},
{"path": "../base-driver"},
{"path": "../types"},
{"path": "../schema"}
{"path": "../schema"},
{"path": "../base-driver"},
{"path": "../base-plugin"}
]
}
+6
View File
@@ -24,6 +24,12 @@ export interface InternalMetadata {
* Whatever the user typed as the extension to install. May be derived from `package.json`
*/
installSpec: string;
/**
* Maximum version of Appium that this extension is compatible with.
*
* If `undefined`, we'll try anyway.
*/
appiumVersion?: string;
}
/**
+2 -12
View File
@@ -1,15 +1,5 @@
import type {BaseDriverBase} from '@appium/base-driver/lib/basedriver/driver';
import {Class, Driver, ExternalDriver} from '@appium/types';
import {DriverType, ExtensionType, PluginType} from '.';
export type DriverClass = BaseDriverBase<ExternalDriver, ExternalDriverStatic>;
/**
* Additional static props for external driver classes
*/
export interface ExternalDriverStatic {
driverName: string;
}
import {DriverClass} from '@appium/base-driver';
import {Class, Driver, DriverType, PluginType} from '@appium/types';
/**
* TODO: This should be derived from the base plugin.
+10 -3
View File
@@ -4,7 +4,7 @@
import type {SchemaObject} from 'ajv';
import type {PackageJson, SetRequired} from 'type-fest';
import {DriverType, ExtensionType, PluginType} from './index';
import {DriverType, ExtensionType, PluginType} from '@appium/types';
/**
* This is what is allowed in the `appium.schema` prop of an extension's `package.json`.
@@ -49,9 +49,16 @@ export type ExtMetadata<ExtType extends ExtensionType> = (ExtType extends Driver
/**
* A `package.json` containing extension metadata.
* Required fields are `name`, `version`, and `appium`.
* Must have the following properties:
* - `name`: the name of the extension
* - `version`: the version of the extension
* - `appium`: the metadata for the extension
* - `peerDependencies.appium`: the maximum compatible version of Appium
*/
export type ExtPackageJson<ExtType extends ExtensionType> = SetRequired<
PackageJson,
'name' | 'version'
> & {appium: ExtMetadata<ExtType>};
> & {
appium: ExtMetadata<ExtType>;
peerDependencies: {appium: string; [key: string]: string};
};
-4
View File
@@ -1,8 +1,4 @@
{
"exclude": [
"packages/**/test/**",
"packages/**/build/**"
],
"files": [],
"references": [
{