diff --git a/package.json b/package.json index e5b9de1aa..cf618b185 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "doctor": "node ./packages/doctor", "e2e-test": "lerna exec \"gulp e2e-test\"", "generate-docs": "lerna run --scope=appium generate-docs", + "pregenerate-schema-declarations": "lerna exec --scope=appium \"gulp transpile\"", + "generate-schema-declarations": "node ./scripts/generate-schema-declarations.js", "postinstall": "lerna bootstrap && lerna exec --parallel \"gulp prepublish\"", "install-fake-driver": "lerna run --scope=appium install-fake-driver", "lint": "lerna exec --parallel \"gulp lint\" && lerna run --scope=@appium/eslint-config-appium lint", @@ -56,7 +58,7 @@ "test:gulp-plugins": "lerna exec --scope=@appium/gulp-plugins \"gulp once\"", "test:support": "lerna exec --scope=@appium/support \"gulp once\"", "test:test-support": "lerna exec --scope=@appium/test-support \"gulp once\"", - "preversion": "npm run sync-pkgs && git add -A packages/appium/README.md packages/*/package.json", + "preversion": "npm run sync-pkgs && npm run generate-schema-declarations && git add -A packages/appium/README.md packages/*/package.json", "watch": "lerna exec watch" }, "pre-commit": [ @@ -64,7 +66,8 @@ "precommit-lint" ], "lint-staged": { - "*.js": "eslint --fix" + "*.js": "eslint --fix", + "packages/appium/lib/appium-config-schema.js": "npm run generate-schema-declarations && git add -A ./packages/appium/types/appium-config.d.ts" }, "devDependencies": { "@appium/eslint-config-appium": "file:./packages/eslint-config-appium", @@ -101,6 +104,7 @@ "get-port": "5.1.1", "gulp": "4.0.2", "handlebars": "4.7.7", + "json-schema-to-typescript": "10.1.4", "lerna": "4.0.0", "lint-staged": "11.2.6", "mjpeg-server": "0.3.1", @@ -112,6 +116,7 @@ "sinon": "11.1.2", "sinon-chai": "3.7.0", "sync-monorepo-packages": "0.3.4", + "through2": "4.0.2", "type-fest": "2.5.2", "validate.js": "0.13.1", "webdriverio": "6.12.1", diff --git a/packages/appium/gulpfile.js b/packages/appium/gulpfile.js index 0ce454932..72684cd38 100644 --- a/packages/appium/gulpfile.js +++ b/packages/appium/gulpfile.js @@ -1,3 +1,4 @@ +// @ts-check /* eslint no-console:0 */ /* eslint-disable promise/prefer-await-to-callbacks */ 'use strict'; @@ -11,8 +12,47 @@ const boilerplate = require('@appium/gulp-plugins').boilerplate.use(gulp); const path = require('path'); const fs = require('fs'); const log = require('fancy-log'); +const {obj: through} = require('through2'); -gulp.task('copy-fixtures', () => gulp.src('./test/fixtures/*').pipe(gulp.dest('./build/test/fixtures/'))); +const APPIUM_CONFIG_SCHEMA_BASENAME = 'appium-config.schema.json'; + +/** + * Expects a single file (as defined by `APPIUM_CONFIG_SCHEMA_PATH`) and converts + * that file to JSON. + * @param {import('vinyl')} file - Vinyl file object + * @param {BufferEncoding} enc - Encoding + * @param {import('through2').TransformCallback} done - Callback + */ +function writeAppiumConfigJsonSchema (file, enc, done) { + try { + const {default: schema} = require(file.path); + // @ts-ignore + file.contents = Buffer.from(JSON.stringify(schema, null, 2)); + file.basename = APPIUM_CONFIG_SCHEMA_BASENAME; + done(null, file); + } catch (err) { + done(err); + } +} + +// non-JS files that should be copied into the build dir (since babel does not compile them) +gulp.task('copy-files', gulp.parallel( + function copyTestFixtures () { + return gulp.src('./test/fixtures/*.{txt,yaml,json}') + .pipe(gulp.dest('./build/test/fixtures')); + }, + function copyTestConfigFixtures () { + return gulp.src('./test/fixtures/config/*.{txt,yaml,json}') + .pipe(gulp.dest('./build/test/fixtures/config')); + } +)); + +gulp.task('generate-appium-schema-json', function () { + // don't care about file contents as text, so `read: false` + return gulp.src('./build/lib/appium-config-schema.js', {read: false}) + .pipe(through(writeAppiumConfigJsonSchema)) + .pipe(gulp.dest('./build/lib/')); +}); boilerplate({ build: 'appium', @@ -27,11 +67,12 @@ boilerplate({ files: ['${testDir}/**/*-specs.js'] }, testTimeout: 160000, - postTranspile: ['copy-fixtures'] + postTranspile: ['copy-files', 'generate-appium-schema-json'] }); // generates server arguments readme -gulp.task('docs', gulp.series(['transpile']), function parseDocs () { +gulp.task('docs', gulp.series(['transpile', function parseDocs () { + // @ts-ignore const parser = require('./build/lib/parser.js'); const appiumArguments = parser.getParser().rawArgs; const docFile = path.resolve(__dirname, 'docs/en/writing-running-appium/server-args.md'); @@ -76,4 +117,4 @@ gulp.task('docs', gulp.series(['transpile']), function parseDocs () { log('New docs written! Do not forget to commit and push'); } }); -}); +}])); diff --git a/packages/appium/lib/appium-config-schema.js b/packages/appium/lib/appium-config-schema.js new file mode 100644 index 000000000..86782d75f --- /dev/null +++ b/packages/appium/lib/appium-config-schema.js @@ -0,0 +1,266 @@ +export default { + $schema: 'http://json-schema.org/draft-07/schema', + $id: 'appium.json', + type: 'object', + title: 'Appium Configuration', + description: 'A schema for Appium configuration files', + properties: { + server: { + $id: '#/properties/server', + type: 'object', + title: 'server config', + description: 'Configuration when running Appium as a server', + properties: { + address: { + $comment: 'I think hostname covers both DNS and IPv4...could be wrong', + $id: '#/properties/server/properties/address', + appiumCliAliases: ['a'], + default: '0.0.0.0', + description: 'IP address to listen on', + format: 'hostname', + title: 'address config', + type: 'string' + }, + 'allow-cors': { + $id: '#/properties/server/properties/allow-cors', + default: false, + description: 'Whether the Appium server should allow web browser connections from any host', + title: 'allow-cors config', + type: 'boolean' + }, + 'allow-insecure': { + $id: '#/properties/server/properties/allow-insecure', + default: [], + description: 'Set which insecure features are allowed to run in this server\'s sessions. Features are defined on a driver level; see documentation for more details. Note that features defined via "deny-insecure" will be disabled, even if also listed here. If string, a path to a text file containing policy or a comma-delimited list.', + items: { + type: 'string' + }, + title: 'allow-insecure config', + type: ['array', 'string'], + uniqueItems: true + }, + 'base-path': { + $id: '#/properties/server/properties/base-path', + appiumCliAliases: ['pa'], + default: '', + description: 'Base path to use as the prefix for all webdriver routes running on the server', + title: 'base-path config', + type: 'string' + }, + 'callback-address': { + $id: '#/properties/server/properties/callback-address', + appiumCliAliases: ['ca'], + description: 'Callback IP address (default: same as "address")', + title: 'callback-address config', + type: 'string' + }, + 'callback-port': { + $id: '#/properties/server/properties/callback-port', + appiumCliAliases: ['cp'], + default: 4723, + description: 'Callback port (default: same as "port")', + maximum: 65535, + minimum: 1, + title: 'callback-port config', + type: 'integer' + }, + 'debug-log-spacing': { + $id: '#/properties/server/properties/debug-log-spacing', + default: false, + description: 'Add exaggerated spacing in logs to help with visual inspection', + title: 'debug-log-spacing config', + type: 'boolean' + }, + 'default-capabilities': { + $comment: 'TODO', + $id: '#/properties/server/properties/default-capabilities', + appiumCliAliases: ['dc'], + description: 'Set the default desired capabilities, which will be set on each session unless overridden by received capabilities. If a string, a path to a JSON file containing the capabilities, or raw JSON.', + title: 'default-capabilities config', + type: ['object', 'string'] + }, + 'deny-insecure': { + $comment: 'Allowed values are defined by drivers', + $id: '#/properties/server/properties/deny-insecure', + default: [], + description: 'Set which insecure features are not allowed to run in this server\'s sessions. Features are defined on a driver level; see documentation for more details. Features listed here will not be enabled even if also listed in "allow-insecure", and even if "relaxed-security" is enabled. If string, a path to a text file containing policy or a comma-delimited list.', + items: { + type: 'string' + }, + title: 'deny-insecure config', + type: ['array', 'string'], + uniqueItems: true + }, + drivers: { + $id: '#/properties/server/properties/drivers', + default: '', + description: 'A list of drivers to activate. By default, all installed drivers will be activated.', + items: { + type: 'string' + }, + title: 'drivers config', + type: ['string', 'array'] + }, + 'keep-alive-timeout': { + $id: '#/properties/server/properties/keep-alive-timeout', + appiumCliAliases: ['ka'], + default: 600, + description: 'Number of seconds the Appium server should apply as both the keep-alive timeout and the connection timeout for all requests. A value of 0 disables the timeout.', + minimum: 0, + title: 'keep-alive-timeout config', + type: 'integer' + }, + 'local-timezone': { + $id: '#/properties/server/properties/local-timezone', + default: false, + description: 'Use local timezone for timestamps', + title: 'local-timezone config', + type: 'boolean' + }, + log: { + $id: '#/properties/server/properties/log', + appiumCliAliases: ['g'], + appiumCliDest: 'logFile', + description: 'Also send log output to this file', + title: 'log config', + type: 'string' + }, + 'log-filters': { + $comment: 'TODO', + $id: '#/properties/log-filters', + description: 'One or more log filtering rules', + items: { + type: 'string' + }, + title: 'log-filters config', + type: 'array' + }, + 'log-level': { + $id: '#/properties/server/properties/log-level', + appiumCliDest: 'loglevel', + default: 'debug', + description: 'Log level (console[:file])', + enum: ['info', 'info:debug', 'info:info', 'info:warn', 'info:error', 'warn', 'warn:debug', 'warn:info', 'warn:warn', 'warn:error', 'error', 'error:debug', 'error:info', 'error:warn', 'error:error', 'debug', 'debug:debug', 'debug:info', 'debug:warn', 'debug:error'], + title: 'log-level config', + type: 'string' + }, + 'log-no-colors': { + $id: '#/properties/server/properties/log-no-colors', + default: false, + description: 'Do not use color in console output', + title: 'log-no-colors config', + type: 'boolean' + }, + 'log-timestamp': { + $id: '#/properties/server/properties/log-timestamp', + default: false, + description: 'Show timestamps in console output', + title: 'log-timestamp config', + type: 'boolean' + }, + 'long-stacktrace': { + $id: '#/properties/server/properties/long-stacktrace', + default: false, + description: 'Add long stack traces to log entries. Recommended for debugging only.', + title: 'long-stacktrace config', + type: 'boolean' + }, + 'no-perms-check': { + $id: '#/properties/server/properties/no-perms-check', + default: false, + description: 'Do not check that needed files are readable and/or writable', + title: 'no-perms-check config', + type: 'boolean' + }, + nodeconfig: { + $comment: 'Selenium Grid 3 is unmaintained and Selenium Grid 4 no longer supports this file.', + $id: '#/properties/server/properties/nodeconfig', + default: '', + description: 'Path to configuration JSON file to register Appium as a node with Selenium Grid 3; otherwise the configuration itself', + title: 'nodeconfig config', + type: ['object', 'string'] + }, + plugins: { + $id: '#/properties/server/properties/plugins', + default: '', + description: 'A list of plugins to activate. To activate all plugins, use the single string "all"', + items: { + type: 'string' + }, + title: 'plugins config', + type: ['string', 'array'] + }, + port: { + $id: '#/properties/server/properties/port', + appiumCliAliases: ['p'], + default: 4723, + description: 'Port to listen on', + maximum: 65535, + minimum: 1, + title: 'port config', + type: 'integer' + }, + 'relaxed-security': { + $id: '#/properties/server/properties/relaxed-security', + default: false, + description: 'Disable additional security checks, so it is possible to use some advanced features, provided by drivers supporting this option. Only enable it if all the clients are in the trusted network and it\'s not the case if a client could potentially break out of the session sandbox. Specific features can be overridden by using "deny-insecure"', + title: 'relaxed-security config', + type: 'boolean' + }, + 'session-override': { + $id: '#/properties/server/properties/session-override', + default: false, + description: 'Enables session override (clobbering)', + title: 'session-override config', + type: 'boolean' + }, + 'strict-caps': { + $id: '#/properties/server/properties/strict-caps', + default: false, + description: 'Cause sessions to fail if desired caps are sent in that Appium does not recognize as valid for the selected device', + title: 'strict-caps config', + type: 'boolean' + }, + tmp: { + $id: '#/properties/server/properties/tmp', + description: 'Absolute path to directory Appium can use to manage temp files. Defaults to C:\\Windows\\Temp on Windows and /tmp otherwise.', + title: 'tmp config', + type: 'string' + }, + 'trace-dir': { + $id: '#/properties/server/properties/trace-dir', + description: 'Absolute path to directory Appium can use to save iOS instrument traces; defaults to /appium-instruments', + title: 'trace-dir config', + type: 'string' + }, + webhook: { + $comment: 'This should probably use a uri-template format to restrict the protocol to http/https', + $id: '#/properties/server/properties/webhook', + appiumCliAliases: ['G'], + description: 'Also send log output to this http listener', + format: 'uri', + title: 'webhook config', + type: 'string' + } + }, + additionalProperties: false, + }, + driver: { + $id: '#/properties/driver', + type: 'object', + title: 'driver config', + description: + 'Driver-specific configuration. Keys should correspond to driver package names', + properties: {} + }, + plugin: { + $id: '#/properties/plugin', + type: 'object', + title: 'plugin config', + description: + 'Plugin-specific configuration. Keys should correspond to plugin package names', + properties: {} + }, + }, + additionalProperties: false, +}; diff --git a/packages/appium/lib/appium.js b/packages/appium/lib/appium.js index aeca4a272..b6e9fd9d7 100644 --- a/packages/appium/lib/appium.js +++ b/packages/appium/lib/appium.js @@ -5,8 +5,9 @@ import { findMatchingDriver } from './drivers'; import { BaseDriver, errors, isSessionCommand } from '@appium/base-driver'; import B from 'bluebird'; import AsyncLock from 'async-lock'; -import { getExtensionArgs, parseCapsForInnerDriver, pullSettings, validateExtensionArgs } from './utils'; +import { parseCapsForInnerDriver, pullSettings, validateExtensionArgs } from './utils'; import { util } from '@appium/support'; +import { hasRegisteredSchema } from './schema'; const desiredCapabilityConstraints = { automationName: { @@ -113,22 +114,26 @@ class AppiumDriver extends BaseDriver { /** * Validate and assign CLI args for a driver or plugin - * @param {string} extType 'driver' or 'plugin' + * + * If the extension has provided a schema, validation has already happened. + * @param {'driver'|'plugin'} extType 'driver' or 'plugin' * @param {string} extName the name of the extension * @param {ObjectConstructor} extClass the class of the extension * @param {Object} extInstance the driver or plugin instance */ assignCliArgsToExtension (extType, extName, extClass, extInstance) { - const cliArgs = getExtensionArgs(this.args[`${extType}Args`], extName); - if (!_.isEmpty(cliArgs)) { + const cliArgs = this.args[extType]?.[extName]; + if (!hasRegisteredSchema(extType, extName) && !_.isEmpty(cliArgs)) { if (!_.has(extClass, 'argsConstraints')) { - throw new Error(`You sent in CLI args for the ${extName} ${extType}, but it ` + - `does not define any`); + throw new Error(`You sent in CLI args for the ${extName} "${extType}", but it ` + + `does not define any`); } validateExtensionArgs(cliArgs, extClass.argsConstraints); - extInstance.cliArgs = cliArgs; log.debug(`Set CLI arguments on ${extName} ${extType} instance: ${JSON.stringify(cliArgs)}`); } + if (!_.isEmpty(cliArgs)) { + extInstance.cliArgs = cliArgs; + } } /** diff --git a/packages/appium/lib/cli/args.js b/packages/appium/lib/cli/args.js index 88eb62fb4..38c932d77 100644 --- a/packages/appium/lib/cli/args.js +++ b/packages/appium/lib/cli/args.js @@ -1,318 +1,20 @@ import { DEFAULT_BASE_PATH } from '@appium/base-driver'; +import _ from 'lodash'; +import DriverConfig from '../driver-config'; +import { APPIUM_HOME, DRIVER_TYPE, INSTALL_TYPES, PLUGIN_TYPE } from '../extension-config'; +import PluginConfig from '../plugin-config'; import { - parseSecurityFeatures, parseJsonStringOrFile, - parsePluginNames, parseInstallTypes, parseDriverNames + parseDriverNames, parseInstallTypes, parseJsonStringOrFile, + parsePluginNames, parseSecurityFeatures } from './parser-helpers'; -import { - INSTALL_TYPES, DEFAULT_APPIUM_HOME, - DRIVER_TYPE, PLUGIN_TYPE -} from '../extension-config'; -import { - DEFAULT_CAPS_ARG -} from './argparse-actions'; +import { toParserArgs } from './schema-args'; const DRIVER_EXAMPLE = 'xcuitest'; const PLUGIN_EXAMPLE = 'find_by_image'; const USE_ALL_PLUGINS = 'all'; -// sharedArgs will be added to every subcommand -const sharedArgs = [ - [['-ah', '--home', '--appium-home'], { - required: false, - default: process.env.APPIUM_HOME || DEFAULT_APPIUM_HOME, - help: 'The path to the directory where Appium will keep installed drivers, plugins, and any other metadata necessary for its operation', - dest: 'appiumHome', - }], - - [['--log-filters'], { - dest: 'logFilters', - default: null, - required: false, - action: 'store_true', - help: 'Set the full path to a JSON file containing one or more log filtering rules', - }], -]; - -const serverArgs = [ - [['--shell'], { - required: false, - default: null, - help: 'Enter REPL mode', - action: 'store_true', - dest: 'shell', - }], - - [['--drivers'], { - required: false, - default: [], - help: `A comma-separated list of installed driver names that should be active for this ` + - `server. All drivers will be active by default.`, - type: parseDriverNames, - dest: 'drivers', - }], - - [['--plugins'], { - required: false, - default: [], - help: `A comma-separated list of installed plugin names that should be active for this ` + - `server. To activate all plugins, you can use the single string "${USE_ALL_PLUGINS}" ` + - `as the value (e.g. --plugins=${USE_ALL_PLUGINS})`, - type: parsePluginNames, - dest: 'plugins', - }], - - [['--allow-cors'], { - required: false, - default: false, - action: 'store_true', - help: 'Whether the Appium server should allow web browser connections from any host', - dest: 'allowCors', - }], - - - [['-a', '--address'], { - default: '0.0.0.0', - required: false, - help: 'IP Address to listen on', - dest: 'address', - }], - - [['-p', '--port'], { - default: 4723, - required: false, - type: 'int', - help: 'port to listen on', - dest: 'port', - }], - - [['-pa', '--base-path'], { - required: false, - default: DEFAULT_BASE_PATH, - dest: 'basePath', - help: 'Base path to use as the prefix for all webdriver routes running ' + - `on this server` - }], - - [['-ka', '--keep-alive-timeout'], { - required: false, - default: null, - dest: 'keepAliveTimeout', - type: 'int', - help: 'Number of seconds the Appium server should apply as both the keep-alive timeout ' + - 'and the connection timeout for all requests. Defaults to 600 (10 minutes).' - }], - - [['-ca', '--callback-address'], { - required: false, - dest: 'callbackAddress', - default: null, - help: 'callback IP Address (default: same as --address)', - }], - - [['-cp', '--callback-port'], { - required: false, - dest: 'callbackPort', - default: null, - type: 'int', - help: 'callback port (default: same as port)', - }], - - [['--session-override'], { - default: false, - dest: 'sessionOverride', - action: 'store_true', - required: false, - help: 'Enables session override (clobbering)', - }], - - [['-g', '--log'], { - default: null, - dest: 'logFile', - required: false, - help: 'Also send log output to this file', - }], - - [['--log-level'], { - choices: [ - 'info', 'info:debug', 'info:info', 'info:warn', 'info:error', - 'warn', 'warn:debug', 'warn:info', 'warn:warn', 'warn:error', - 'error', 'error:debug', 'error:info', 'error:warn', 'error:error', - 'debug', 'debug:debug', 'debug:info', 'debug:warn', 'debug:error', - ], - default: 'debug', - dest: 'loglevel', - required: false, - help: 'log level; default (console[:file]): debug[:debug]', - }], - - [['--log-timestamp'], { - default: false, - required: false, - help: 'Show timestamps in console output', - action: 'store_true', - dest: 'logTimestamp', - }], - - [['--local-timezone'], { - default: false, - required: false, - help: 'Use local timezone for timestamps', - action: 'store_true', - dest: 'localTimezone', - }], - - [['--log-no-colors'], { - default: false, - required: false, - help: 'Do not use colors in console output', - action: 'store_true', - dest: 'logNoColors', - }], - - [['-G', '--webhook'], { - default: null, - required: false, - dest: 'webhook', - help: 'Also send log output to this HTTP listener, for example localhost:9876', - }], - - [['--nodeconfig'], { - required: false, - default: null, - dest: 'nodeconfig', - help: 'Configuration JSON file to register appium with selenium grid', - }], - - [['--show-config'], { - default: false, - dest: 'showConfig', - action: 'store_true', - required: false, - help: 'Show info about the appium server configuration and exit', - }], - - [['--no-perms-check'], { - default: false, - dest: 'noPermsCheck', - action: 'store_true', - required: false, - help: 'Bypass Appium\'s checks to ensure we can read/write necessary files', - }], - - [['--strict-caps'], { - default: false, - dest: 'enforceStrictCaps', - action: 'store_true', - required: false, - help: 'Cause sessions to fail if desired caps are sent in that Appium ' + - 'does not recognize as valid for the selected device', - }], - - [['--tmp'], { - default: null, - dest: 'tmpDir', - required: false, - help: 'Absolute path to directory Appium can use to manage temporary ' + - 'files, like built-in iOS apps it needs to move around. On *nix/Mac ' + - 'defaults to /tmp, on Windows defaults to C:\\Windows\\Temp', - }], - - [['--trace-dir'], { - default: null, - dest: 'traceDir', - required: false, - help: 'Absolute path to directory Appium use to save ios instruments ' + - 'traces, defaults to /appium-instruments', - }], - - [['--debug-log-spacing'], { - dest: 'debugLogSpacing', - default: false, - action: 'store_true', - required: false, - help: 'Add exaggerated spacing in logs to help with visual inspection', - }], - - - [['--long-stacktrace'], { - dest: 'longStacktrace', - default: false, - required: false, - action: 'store_true', - help: 'Add long stack traces to log entries. Recommended for debugging only.', - }], - - - [['-dc', DEFAULT_CAPS_ARG], { - dest: 'defaultCapabilities', - default: {}, - type: parseJsonStringOrFile, - required: false, - help: 'Set the default desired capabilities, which will be set on each ' + - 'session unless overridden by received capabilities. For example: ' + - '[ \'{"app": "myapp.app", "deviceName": "iPhone Simulator"}\' ' + - '| /path/to/caps.json ]' - }], - - [['--relaxed-security'], { - default: false, - dest: 'relaxedSecurityEnabled', - action: 'store_true', - required: false, - help: 'Disable additional security checks, so it is possible to use some advanced features, provided ' + - 'by drivers supporting this option. Only enable it if all the ' + - 'clients are in the trusted network and it\'s not the case if a client could potentially ' + - 'break out of the session sandbox. Specific features can be overridden by ' + - 'using the --deny-insecure flag', - }], - - [['--allow-insecure'], { - dest: 'allowInsecure', - default: [], - type: parseSecurityFeatures, - required: false, - help: 'Set which insecure features are allowed to run in this server\'s sessions. ' + - 'Features are defined on a driver level; see documentation for more details. ' + - 'This should be either a comma-separated list of feature names, or a path to ' + - 'a file where each feature name is on a line. Note that features defined via ' + - '--deny-insecure will be disabled, even if also listed here.', - }], - - [['--deny-insecure'], { - dest: 'denyInsecure', - default: [], - type: parseSecurityFeatures, - required: false, - help: 'Set which insecure features are not allowed to run in this server\'s sessions. ' + - 'Features are defined on a driver level; see documentation for more details. ' + - 'This should be either a comma-separated list of feature names, or a path to ' + - 'a file where each feature name is on a line. Features listed here will not be ' + - 'enabled even if also listed in --allow-insecure, and even if --relaxed-security ' + - 'is turned on. For example: execute_driver_script,adb_shell', - }], - - [['--driver-args'], { - dest: 'driverArgs', - default: {}, - type: parseJsonStringOrFile, - required: false, - help: 'Set the default desired client arguments for a driver, ' + - 'For example: ' + - '[ \'{"xcuitest": {"foo1": "bar1", "foo2": "bar2"}}\' ' + - '| /path/to/driverArgs.json ]' - }], - - [['--plugin-args'], { - dest: 'pluginArgs', - default: {}, - type: parseJsonStringOrFile, - required: false, - help: 'Set the default desired client arguments for a plugin, ' + - 'For example: ' + - '[ \'{"images": {"foo1": "bar1", "foo2": "bar2"}}\' ' + - '| /path/to/pluginArgs.json ]' - }], -]; +const driverConfig = new DriverConfig(APPIUM_HOME); +const pluginConfig = new PluginConfig(APPIUM_HOME); // this set of args works for both drivers and plugins ('extensions') const globalExtensionArgs = [ @@ -325,8 +27,25 @@ const globalExtensionArgs = [ }] ]; -const extensionArgs = {[DRIVER_TYPE]: {}, [PLUGIN_TYPE]: {}}; +const getExtensionArgs = _.once(function getExtensionArgs () { + const extensionArgs = {[DRIVER_TYPE]: {}, [PLUGIN_TYPE]: {}}; + for (const type of [DRIVER_TYPE, PLUGIN_TYPE]) { + extensionArgs[type] = { + list: makeListArgs(type), + install: makeInstallArgs(type), + uninstall: makeUninstallArgs(type), + update: makeUpdateArgs(type), + run: makeRunArgs(type), + }; + } + return extensionArgs; +}); +/** + * + * @param {ExtensionType} type + * @returns + */ function makeListArgs (type) { return [ ...globalExtensionArgs, @@ -423,17 +142,87 @@ function makeRunArgs (type) { ]; } -for (const type of [DRIVER_TYPE, PLUGIN_TYPE]) { - extensionArgs[type].list = makeListArgs(type); - extensionArgs[type].install = makeInstallArgs(type); - extensionArgs[type].uninstall = makeUninstallArgs(type); - extensionArgs[type].update = makeUpdateArgs(type); - extensionArgs[type].run = makeRunArgs(type); +function getServerArgs () { + return [ + ...toParserArgs({ + overrides: { + allowInsecure: { + type: parseSecurityFeatures + }, + basePath: { + default: DEFAULT_BASE_PATH + }, + defaultCapabilities: { + type: parseJsonStringOrFile + }, + denyInsecure: { + type: parseSecurityFeatures + }, + drivers: { + type: parseDriverNames + }, + nodeconfig: { + type: parseJsonStringOrFile + }, + plugins: { + type: parsePluginNames + } + } + }), + ...serverArgsDisallowedInConfig, + ]; } +/** + * These don't make sense in the context of a config file for obvious reasons. + */ +const serverArgsDisallowedInConfig = [ + [ + ['--shell'], + { + required: false, + default: null, + help: 'Enter REPL mode', + action: 'store_true', + dest: 'shell', + }, + ], + [ + ['--show-config'], + { + default: false, + dest: 'showConfig', + action: 'store_true', + required: false, + help: 'Show info about the appium server configuration and exit', + }, + ], + [ + ['--config'], + { + dest: 'configFile', + type: 'string', + required: false, + help: 'Explicit path to Appium configuration file', + }, + ], +]; + export { - sharedArgs, - serverArgs, - extensionArgs, + getServerArgs, + getExtensionArgs, USE_ALL_PLUGINS, + driverConfig, + pluginConfig, + APPIUM_HOME }; + +/** + * Alias + * @typedef {import('../ext-config-io').ExtensionType} ExtensionType + */ + +/** + * A tuple of argument aliases and argument options + * @typedef {[string[], import('argparse').ArgumentOptions]} ArgumentDefinition + */ diff --git a/packages/appium/lib/cli/extension.js b/packages/appium/lib/cli/extension.js index 4284b5446..986330b47 100644 --- a/packages/appium/lib/cli/extension.js +++ b/packages/appium/lib/cli/extension.js @@ -6,6 +6,7 @@ import DriverConfig from '../driver-config'; import PluginConfig from '../plugin-config'; import { DRIVER_TYPE } from '../extension-config'; import { errAndQuit, log, JSON_SPACES } from './utils'; +import { APPIUM_HOME } from './args'; /** * Run a subcommand of the 'appium driver' type. Each subcommand has its own set of arguments which @@ -14,7 +15,7 @@ import { errAndQuit, log, JSON_SPACES } from './utils'; * @param {Object} args - JS object where the key is the parameter name (as defined in * driver-parser.js) */ -async function runExtensionCommand (args, type) { +async function runExtensionCommand (args, type, configObject) { // TODO driver config file should be locked while any of these commands are // running to prevent weird situations let jsonResult = null; @@ -22,14 +23,20 @@ async function runExtensionCommand (args, type) { if (!extCmd) { throw new TypeError(`Cannot call ${type} command without a subcommand like 'install'`); } - let {json, suppressOutput, appiumHome} = args; + let {json, suppressOutput} = args; if (suppressOutput) { json = true; } const logFn = (msg) => log(json, msg); - const ConfigClass = type === DRIVER_TYPE ? DriverConfig : PluginConfig; + let config; + if (!configObject) { + const ConfigClass = type === DRIVER_TYPE ? DriverConfig : PluginConfig; + config = new ConfigClass(APPIUM_HOME, logFn); + } else { + config = configObject; + config.log = logFn; + } const CommandClass = type === DRIVER_TYPE ? DriverCommand : PluginCommand; - const config = new ConfigClass(appiumHome, logFn); const cmd = new CommandClass({config, json}); try { await config.read(); diff --git a/packages/appium/lib/cli/parser-helpers.js b/packages/appium/lib/cli/parser-helpers.js index 1b82c2dda..de3d05a86 100644 --- a/packages/appium/lib/cli/parser-helpers.js +++ b/packages/appium/lib/cli/parser-helpers.js @@ -4,16 +4,21 @@ import { INSTALL_TYPES } from '../extension-config'; // serverArgs will be added to the `server` (default) subcommand function parseSecurityFeatures (features) { + let parsedFeatures; const splitter = (splitOn, str) => `${str}`.split(splitOn) .map((s) => s.trim()) .filter(Boolean); - let parsedFeatures; - try { - parsedFeatures = splitter(',', features); - } catch (err) { - throw new TypeError('Could not parse value of --allow/deny-insecure. Should be ' + - 'a list of strings separated by commas, or a path to a file ' + - 'listing one feature name per line.'); + if (_.isString(features)) { + try { + parsedFeatures = splitter(',', features); + } catch (err) { + throw new TypeError('Could not parse value of --allow/deny-insecure. Should be ' + + 'a list of strings separated by commas, or a path to a file ' + + 'listing one feature name per line.'); + } + } else { + // it's an array + parsedFeatures = features; } if (parsedFeatures.length === 1 && fs.existsSync(parsedFeatures[0])) { diff --git a/packages/appium/lib/cli/parser.js b/packages/appium/lib/cli/parser.js index 1f959ad01..9dd935691 100644 --- a/packages/appium/lib/cli/parser.js +++ b/packages/appium/lib/cli/parser.js @@ -1,121 +1,224 @@ -import path from 'path'; +import {fs} from '@appium/support'; +import {ArgumentParser} from 'argparse'; +import B from 'bluebird'; import _ from 'lodash'; -import { ArgumentParser } from 'argparse'; -import { sharedArgs, serverArgs, extensionArgs } from './args'; -import { DRIVER_TYPE, PLUGIN_TYPE } from '../extension-config'; -import { rootDir } from '../utils'; -import { fs } from '@appium/support'; +import path from 'path'; +import {DRIVER_TYPE, PLUGIN_TYPE} from '../extension-config'; +import {finalizeSchema} from '../schema'; +import {parseArgName} from './schema-args'; +import {rootDir} from '../utils'; +import { + driverConfig, + getExtensionArgs, + getServerArgs, + pluginConfig, +} from './args'; +import {handle, CLIError} from '@oclif/errors'; +const version = fs.readPackageJsonFrom(rootDir).version; -function makeDebugParser (parser) { - parser.exit = (status, message = undefined) => { - throw new Error(message); - }; -} +/** + * A wrapper around `argparse` + * + * - Handles instantiation, configuration, and monkeypatching of an + * `ArgumentParser` instance for Appium server and its extensions + * - Handles error conditions, messages, and exit behavior + */ +class ArgParser { + constructor (debug = false) { + const prog = (this.prog = process.argv[1] + ? path.basename(process.argv[1]) + : 'appium'); + const parser = new ArgumentParser({ + add_help: true, + description: + 'A webdriver-compatible server for use with native and hybrid iOS and Android applications.', + prog, + exit_on_error: false, + }); -function getParser (debug = false) { - const parser = new ArgumentParser({ - add_help: true, - description: 'A webdriver-compatible server for use with native and hybrid iOS and Android applications.', - prog: process.argv[1] ? path.basename(process.argv[1]) : 'appium', - }); - if (debug) { - makeDebugParser(parser); + ArgParser._patchExit(parser); + + this.debug = debug; + + this.parser = parser; + + parser.add_argument('-v', '--version', { + action: 'version', + version, + }); + + const subParsers = parser.add_subparsers({dest: 'subcommand'}); + + // add the 'server' subcommand, and store the raw arguments on the parser + // object as a way for other parts of the code to work with the arguments + // conceptually rather than just through argparse + const serverArgs = ArgParser._addServerToParser(subParsers, debug); + this.rawArgs = serverArgs; + + // add the 'driver' and 'plugin' subcommands + ArgParser._addExtensionCommandsToParser(subParsers, debug); + + // backwards compatibility / drop-in wrapper + this.parse_args = this.parseArgs; } - parser.add_argument('-v', '--version', { - action: 'version', - version: fs.readPackageJsonFrom(rootDir).version - }); - const subParsers = parser.add_subparsers({dest: 'subcommand'}); - // add the 'server' subcommand, and store the raw arguments on the parser - // object as a way for other parts of the code to work with the arguments - // conceptually rather than just through argparse - const serverArgs = addServerToParser(sharedArgs, subParsers, debug); - parser.rawArgs = serverArgs; + /** + * Parse arguments from the command line. + * + * If no subcommand is passed in, this method will inject the `server` subcommand. + * + * `ArgParser.prototype.parse_args` is an alias of this method. + * @param {string[]} [args] - Array of arguments, ostensibly from `process.argv`. Gathers args from `process.argv` if not provided. + * @returns {object} - The parsed arguments + */ + parseArgs (args) { + args = args ?? process.argv.slice(2); - // add the 'driver' and 'plugin' subcommands - addExtensionsToParser(sharedArgs, subParsers, debug); - - // modify the parse_args function to insert the 'server' subcommand if the - // user hasn't specified a subcommand or the global help command - parser._parse_args = parser.parse_args.bind(parser); - parser.parse_args = function (args, namespace) { - if (_.isUndefined(args)) { - args = [...process.argv.slice(2)]; - } - if (!_.includes([DRIVER_TYPE, PLUGIN_TYPE, 'server', '-h', '--help', '-v', '--version'], args[0])) { + if ( + !_.includes( + [DRIVER_TYPE, PLUGIN_TYPE, 'server', '-h', '--help', '-v', '--version'], + args[0], + ) + ) { args.splice(0, 0, 'server'); } - return this._parse_args(args, namespace); - }.bind(parser); - return parser; -} -function addServerToParser (sharedArgs, subParsers, debug = false) { - const serverParser = subParsers.add_parser('server', { - add_help: true, - help: 'Run an Appium server', - }); - - if (debug) { - makeDebugParser(serverParser); + try { + return ArgParser._unpackExtensionArgDests(this.parser.parse_args(args)); + } catch (err) { + if (this.debug) { + throw err; + } + // eslint-disable-next-line no-console + console.error(); // need an extra space since argparse prints usage. + const cliError = new CLIError(err, {exit: 1}); + // we could add an error code here which would be displayed to the user + // cliError.code = 'APPIUM_BAD_ARGUMENT'; + // or a hyperlink + // cliError.ref = 'https://appium.io/docs/cli#usage' + handle(cliError); + } } - for (const [flagsOrNames, opts] of [...sharedArgs, ...serverArgs]) { - // add_argument mutates arguments so make copies - serverParser.add_argument(...flagsOrNames, {...opts}); + /** + * Given an object full of arguments as returned by `argparser.parse_args`, + * expand the ones for extensions into a nested object structure. + * + * E.g., `{'driver-foo-bar': baz}` becomes `{driver: {foo: {bar: 'baz'}}}` + * @param {object} args + * @returns {object} + */ + static _unpackExtensionArgDests (args) { + return _.reduce( + args, + (unpacked, value, key) => { + const {extensionName, extensionType, argName} = parseArgName(key); + const keyPath = + extensionName && extensionType + ? `${extensionType}.${extensionName}.${argName}` + : argName; + _.set(unpacked, keyPath, value); + return unpacked; + }, + {}, + ); } - return serverArgs; -} - -function getDefaultServerArgs () { - let defaults = {}; - for (let [, arg] of serverArgs) { - defaults[arg.dest] = arg.default; + static _patchExit (parser) { + parser.exit = (code, msg) => { + throw new Error(msg); + }; } - return defaults; -} -function addExtensionsToParser (sharedArgs, subParsers, debug = false) { - for (const type of [DRIVER_TYPE, PLUGIN_TYPE]) { - const extParser = subParsers.add_parser(type, { + static _addServerToParser (subParsers) { + const serverParser = subParsers.add_parser('server', { add_help: true, - help: `Access the ${type} management CLI commands`, + help: 'Run an Appium server', }); - if (debug) { - makeDebugParser(extParser); - } - const extSubParsers = extParser.add_subparsers({ - dest: `${type}Command`, - }); - const parserSpecs = [ - {command: 'list', args: extensionArgs[type].list, - help: `List available and installed ${type}s`}, - {command: 'install', args: extensionArgs[type].install, - help: `Install a ${type}`}, - {command: 'uninstall', args: extensionArgs[type].uninstall, - help: `Uninstall a ${type}`}, - {command: 'update', args: extensionArgs[type].update, - help: `Update installed ${type}s to the latest version`}, - {command: 'run', args: extensionArgs[type].run, - help: `Run a script (defined inside the ${type}'s package.json under the ` + - `“scripts” field inside the “appium” field) from an installed ${type}`} - ]; - for (const {command, args, help} of parserSpecs) { - const parser = extSubParsers.add_parser(command, {help}); - if (debug) { - makeDebugParser(parser); - } - for (const [flagsOrNames, opts] of [...sharedArgs, ...args]) { - // add_argument mutates params so make sure to send in copies instead - parser.add_argument(...flagsOrNames, {...opts}); + ArgParser._patchExit(serverParser); + + const serverArgs = getServerArgs(); + for (const [flagsOrNames, opts] of serverArgs) { + // add_argument mutates arguments so make copies + serverParser.add_argument(...flagsOrNames, {...opts}); + } + + return serverArgs; + } + + static _addExtensionCommandsToParser (subParsers) { + for (const type of [DRIVER_TYPE, PLUGIN_TYPE]) { + const extParser = subParsers.add_parser(type, { + add_help: true, + help: `Access the ${type} management CLI commands`, + }); + + ArgParser._patchExit(extParser); + + const extSubParsers = extParser.add_subparsers({ + dest: `${type}Command`, + }); + const extensionArgs = getExtensionArgs(); + const parserSpecs = [ + { + command: 'list', + args: extensionArgs[type].list, + help: `List available and installed ${type}s`, + }, + { + command: 'install', + args: extensionArgs[type].install, + help: `Install a ${type}`, + }, + { + command: 'uninstall', + args: extensionArgs[type].uninstall, + help: `Uninstall a ${type}`, + }, + { + command: 'update', + args: extensionArgs[type].update, + help: `Update installed ${type}s to the latest version`, + }, + { + command: 'run', + args: extensionArgs[type].run, + help: + `Run a script (defined inside the ${type}'s package.json under the ` + + `“scripts” field inside the “appium” field) from an installed ${type}`, + }, + ]; + + for (const {command, args, help} of parserSpecs) { + const parser = extSubParsers.add_parser(command, {help}); + + ArgParser._patchExit(parser); + + for (const [flagsOrNames, opts] of args) { + // add_argument mutates params so make sure to send in copies instead + parser.add_argument(...flagsOrNames, {...opts}); + } } } } } +/** + * Creates a {@link ArgParser} instance. Necessarily reads extension configuration + * beforehand, and finalizes the config schema. + * + * @constructs ArgParser + * @param {boolean} [debug] - If `true`, throw instead of exit upon parsing error + * @returns {Promise} + */ +async function getParser (debug = false) { + await B.all([driverConfig.read(), pluginConfig.read()]); + finalizeSchema(); + + return new ArgParser(debug); +} + export default getParser; -export { getParser, getDefaultServerArgs }; +export {getParser, ArgParser}; diff --git a/packages/appium/lib/cli/schema-args.js b/packages/appium/lib/cli/schema-args.js new file mode 100644 index 000000000..fff1bce7f --- /dev/null +++ b/packages/appium/lib/cli/schema-args.js @@ -0,0 +1,286 @@ +// @ts-check + +import _ from 'lodash'; +import {flattenSchema, getFormatter} from '../schema'; +import {ArgumentTypeError} from 'argparse'; + + +/** + * This module concerns functions which convert schema definitions to + * `argparse`-compatible data structures, for deriving CLI arguments from a + * schema. + */ + +/** + * Namespace for {@link ArgValidator}s. + * + * These functions perform validation on arguments. Arguments and validators are derived from the schema. In some cases, we can simply use what `ajv` provides, but otherwise we will need to implement our own validators. + * + * Unfortunately, re-use of Ajv to validate the arguments is painful, because of differences in structure and naming (e.g., kebab-case for args and schema vs camelCase for object keys). Further, during validation, there's an assumption that we're working with a _file_ (e.g., a JSON document) instead of just a JS object, which has implications for how errors are displayed. + * @type {Record>} + */ +const argValidators = { + /** + * Asserts a `number` is greater than or equal to a given minimum. + * @type {ArgValidator} + */ + minimum: (min) => (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num >= min) { + return num; + } + throw new ArgumentTypeError( + `Value must be a number greater than or equal to ${min}; received ${ + isNaN(num) ? value : num + }`, + ); + }, + /** + * Asserts a `number` is less than or equal to a given maximum. + * @type {ArgValidator} + */ + maximum: (max) => (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num <= max) { + return num; + } + throw new ArgumentTypeError( + `Value must be a number greater than or equal to ${max}; received ${ + isNaN(num) ? value : num + }`, + ); + }, + /** + * Asserts a `string` or `number` matches a particular Ajv formatter (as provided by `ajv-formats`). + * + * `format` is the unique formatter name, which is a key in the `formats` prop of an `Ajv` instance. + * @see https://npmjs.im/ajv-formats + * @type {ArgValidator} + */ + formatter: (format) => { + let formatter = getFormatter(format); + if (!formatter) { + throw new ReferenceError( + `Unknown formatter "${format}" encountered in schema`, + ); + } + + /** + * This is the _actual_ validation function + * @type {(value: any) => boolean} + */ + let subFormatter; + // in this case, we have a `FormatDefinition` object which contains a `validate` function. + if ( + !(formatter instanceof RegExp) && + typeof formatter === 'object' && + typeof formatter.validate === 'function' + ) { + formatter = formatter.validate; + } + if (formatter instanceof RegExp) { + subFormatter = (value) => /** @type {RegExp} */ (formatter).test(value); + } else if (typeof formatter === 'function') { + subFormatter = formatter; + } else { + // things like "async formatters" may end up here. currently not supported afaik + throw new ReferenceError( + `Formatter "${format}" has unknown type/shape; look it up in \`ajv-formats\` and implement a handler`, + ); + } + + return (value) => { + if (subFormatter(value)) { + return value; + } + throw new ArgumentTypeError( + `Value must be a valid ${format}; received ${value}`, + ); + }; + }, +}; + +/** + * A function which given some `T` and optionally `info` (for use in error + * messages) and returns a validation function that returns the validated value + * (`U`) or throws if the value is invalid. + * + * A "formatter" in JSON schema parlance is sort of a "sub-validator". So, + * e.g., the `type` must be a `string`, but that `string` also must match a + * `RegExp`. + * @template ComparisonValue,ArgType + * @typedef {(cmpValue: ComparisonValue, info?: string) => (value: any) => ArgType} + * ArgValidator + */ + +/** + * Options with alias lengths less than this will be considered "short" flags. + */ +const SHORT_ARG_CUTOFF = 3; + +/** + * Convert an alias (`foo`) to a flag (`--foo`) or a short flag (`-f`). + * @param {string} alias - the alias to convert to a flag + * @returns {string} the flag + */ +function aliasToFlag (alias) { + const isShort = alias.length < SHORT_ARG_CUTOFF; + return !isShort ? `--${_.kebabCase(alias)}` : `-${alias}`; +} + +/** + * Given `alias` and optionally `appiumCliDest`, return the key where the arg parser should store the value. + * + * Extension prefixes are passed through, but everything else is camelCased. + * @param {string} alias - argument alias + * @param {Partial<{prefix: string, appiumCliDest: string}>} [opts] -`appiumCliDest` schema value if present + * @returns {string} + */ +function aliasToDest (alias, {appiumCliDest} = {}) { + const {extensionName, extensionType, argName} = parseArgName(alias); + const baseArgName = _.camelCase(appiumCliDest ?? argName); + return extensionName && extensionType + ? `${extensionType}-${extensionName}-${baseArgName}` + : baseArgName; +} + +/** + * Given option `name`, a JSON schema `subSchema`, and options, return an argument definition + * as understood by `argparse`. + * @param {string} name - Option name + * @param {import('ajv').SchemaObject} subSchema - JSON schema for the option + * @param {SubSchemaToArgDefOptions} [opts] - Options + * @returns {import('./args').ArgumentDefinition} Tuple of flag and options + */ +function subSchemaToArgDef (name, subSchema, opts = {}) { + const {overrides = {}} = opts; + const aliases = [ + aliasToFlag(name), + .../** @type {string[]} */ (subSchema.appiumCliAliases ?? []).map((name) => + aliasToFlag(name), + ), + ]; + + let argOpts = { + required: false, + dest: aliasToDest(name, {appiumCliDest: subSchema.appiumCliDest}), + help: subSchema.description, + }; + + // handle special cases for various types + if (!_.isArray(subSchema.type)) { + switch (subSchema.type) { + case 'boolean': { + argOpts.action = 'store_true'; + break; + } + + case 'number': + // fallthrough + case 'integer': { + const {type, minimum, maximum, format} = subSchema; + const validators = []; + if (_.isFinite(minimum)) { + validators.push(argValidators.minimum(minimum)); + } + if (_.isFinite(maximum)) { + validators.push(argValidators.maximum(maximum)); + } + if (format) { + validators.push(argValidators.formatter(format)); + } + // json schema has number types `number` and `integer`. argparse has `float` and `int`, + // respectively. if we have any specialness (max, min, formats), the type becomes a validator + // function. otherwise, `number` becomes `float` and `integer` becomes `int`. + // `_.flow()` creates a "chain" of funcs, each passing the output to the next. + argOpts.type = validators.length + ? _.flow(validators) + : type === 'number' + ? 'float' + : 'int'; + break; + } + + case 'string': { + // json schema `string` becomes `str`, but I think that's the default anyway + argOpts.type = subSchema.format + ? argValidators.formatter(subSchema.format) + : 'str'; + break; + } + } + } else { + // if we have a prop which can be of multiple types AND we do not override it with + // a custom type via `overrides`, OR an extension does this, we should implement this + // case. + } + + // convert JSON schema `enum` to `choices`. `enum` can contain any JSON type, but `argparse` + // is limited to a single type per arg (I think). so let's make everything a string. + // we should probably document this somewhere. + if (_.isArray(subSchema.enum) && !_.isEmpty(subSchema.enum)) { + argOpts.choices = subSchema.enum.map(String); + } + + // overrides override anything we computed here. usually this involves "custom types", + // which are really just transform functions. + argOpts = _.merge( + argOpts, + /** should the override keys correspond to the prop name or the prop dest? + * the prop dest is computed by {@link aliasToDest}. + */ + overrides[name] ?? (argOpts.dest && overrides[argOpts.dest]) ?? {}, + ); + + return [aliases, argOpts]; +} + +/** + * Converts the current JSON schema plus some metadata into `argparse` arguments. + * + * @param {ToParserArgsOptions} opts - Options + * @throws If schema has not been added to ajv (via `finalize()`) + * @returns {import('./args').ArgumentDefinition[]} An array of tuples of aliases and `argparse` arguments; empty if no schema found + */ +export function toParserArgs (opts = {}) { + const flattened = flattenSchema(); + return _.map(flattened, (value, key) => subSchemaToArgDef(key, value, opts)); +} + +/** + * Given an arg/dest name like `--` then return the parts. + * @param {string} aliasOrDest - Alias to parse + * @returns {{extensionType?: string, extensionName?: string, argName: string}} + */ +export function parseArgName (aliasOrDest) { + const matches = aliasOrDest.match( + /^(?.+?)-(?.+?)-(?.+)$/, + ); + const groups = matches?.groups; + return groups?.argName + ? { + argName: groups.argName, + extensionName: groups.extensionName, + extensionType: groups.extensionType, + } + : {argName: aliasOrDest}; +} + +/** + * CLI-specific option subset for {@link ToParserArgsOptions} + * @typedef {Object} ToParserArgsOptsCli + * @property {import('../schema').ExtData} [extData] - Extension data (from YAML) + * @property {'driver'|'plugin'} [type] - Extension type + */ + +/** + * Options for {@link toParserArgs} + * @typedef {SubSchemaToArgDefOptions} ToParserArgsOptions + */ + +/** + * Options for {@link subSchemaToArgDef}. + * @typedef {Object} SubSchemaToArgDefOptions + * @property {string} [prefix] - The prefix to use for the flag, if any + * @property {{[key: string]: import('argparse').ArgumentOptions}} [overrides] - An object of key/value pairs to override the default values + */ diff --git a/packages/appium/lib/config-file.js b/packages/appium/lib/config-file.js new file mode 100644 index 000000000..fd41cb05f --- /dev/null +++ b/packages/appium/lib/config-file.js @@ -0,0 +1,246 @@ +// @ts-check + +import betterAjvErrors from '@sidvind/better-ajv-errors'; +import { lilconfig } from 'lilconfig'; +import _ from 'lodash'; +import yaml from 'yaml'; +import log from './logger'; +import { getSchema, validate } from './schema'; + +/** + * lilconfig loader to handle `.yaml` files + * @type {import('lilconfig').LoaderSync} + */ +function yamlLoader (filepath, content) { + log.debug(`Attempting to parse ${filepath} as YAML`); + return yaml.parse(content); +} + +/** + * A cache of the raw config file (a JSON string) at a filepath. + * This is used for better error reporting. + * Note that config files needn't be JSON, but it helps if they are. + * @type {Map} + */ +const rawConfig = new Map(); + +/** + * Custom JSON loader that caches the raw config file (for use with `better-ajv-errors`). + * If it weren't for this cache, this would be unnecessary. + * @type {import('lilconfig').LoaderSync} + */ +function jsonLoader (filepath, content) { + log.debug(`Attempting to parse ${filepath} as JSON`); + rawConfig.set(filepath, content); + return JSON.parse(content); +} + +/** + * Loads a config file from an explicit path + * @param {LilconfigAsyncSearcher} lc - lilconfig instance + * @param {string} filepath - Path to config file + * @returns {Promise} + */ +async function loadConfigFile (lc, filepath) { + log.debug(`Attempting to load config at filepath ${filepath}`); + try { + // removing "await" will cause any rejection to _not_ be caught in this block! + return await lc.load(filepath); + } catch (err) { + if (err.code === 'ENOENT') { + err.message = `Config file not found at user-provided path: ${filepath}`; + } else if (err instanceof SyntaxError) { + // generally invalid JSON + err.message = `Config file at user-provided path ${filepath} is invalid:\n${err.message}`; + } + throw err; + } +} + +/** + * Searches for a config file + * @param {LilconfigAsyncSearcher} lc - lilconfig instance + * @returns {Promise} + */ +async function searchConfigFile (lc) { + log.debug('No config file specified; searching...'); + const result = await lc.search(); + if (!result?.filepath) { + log.debug('Could not find an Appium server config file'); + } + return result; +} + + +/** + * Given an array of errors and the result of loading a config file, generate a + * helpful string for the user. + * + * - If `opts` contains a `json` property, this should be the original JSON + * _string_ of the config file. This is only applicable if the config file + * was in JSON format. If present, it will associate line numbers with errors. + * - If `errors` happens to be empty, this will throw. + * @param {import('ajv').ErrorObject[]} errors - Non-empty array of errors. Required. + * @param {import('./config-file').ReadConfigFileResult['config']} [config] - + * Configuration & metadata + * @param {FormatConfigErrorsOptions} [opts] + * @throws {TypeError} If `errors` is empty + * @returns {string} + */ +export function formatConfigErrors (errors = [], config = {}, opts = {}) { + if (errors && !errors.length) { + throw new TypeError('Array of errors must be non-empty'); + } + // cached from the JSON loader; will be `undefined` if not JSON + const json = opts.json; + const format = opts.pretty ?? true ? 'cli' : 'js'; + + return _.join( + betterAjvErrors(getSchema(), config, errors, { + json, + format, + }), + '\n\n', + ); +} + + +/** + * Given an optional path, read a config file. Validates the config file. + * + * Call {@link validate} if you already have a config object. + * @param {string} [filepath] - Path to config file, if we have one + * @param {ReadConfigFileOptions} [opts] - Options + * @public + * @returns {Promise} Contains config and filepath, if found, and any errors + */ +export async function readConfigFile (filepath, opts = {}) { + const lc = lilconfig('appium', { + loaders: { + '.yaml': yamlLoader, + '.yml': yamlLoader, + '.json': jsonLoader, + noExt: jsonLoader, + }, + }); + + const result = filepath ? await loadConfigFile(lc, filepath) : await searchConfigFile(lc); + + if (result && !result.isEmpty && result.filepath) { + log.debug(`Config file found at ${result.filepath}`); + const {normalize = true, pretty = true} = opts; + try { + /** @type {ReadConfigFileResult} */ + let configResult; + const errors = validate(result.config); + if (_.isEmpty(errors)) { + configResult = {...result, errors}; + } else { + const reason = formatConfigErrors(errors, result.config, { + json: rawConfig.get(result.filepath), + pretty, + }); + configResult = reason + ? {...result, errors, reason} + : {...result, errors}; + } + + if (normalize) { + // normalize (to camel case) all top-level property names of the config file + configResult.config = normalizeConfig( + /** @type {AppiumConfiguration} */ (configResult.config), + ); + } + + return configResult; + } finally { + // clean up the raw config file cache, which is only kept to better report errors. + rawConfig.delete(result.filepath); + } + } + return result ?? {}; +} + +/** + * Convert schema property names to either a) the value of the `appiumCliDest` property, if any; or b) camel-case + * @param {AppiumConfiguration} config - Configuration object + * @returns {NormalizedAppiumConfiguration} New object with camel-cased keys. + */ +function normalizeConfig (config) { + /** + * @param {AppiumConfiguration} config + * @param {string} [section] - Keypath (lodash `_.get()` style) to section of config. If omitted, assume root Appium config schema + * @returns Normalized section of config + */ + const normalize = (config, section) => { + const schema = getSchema(); + // @ts-ignore + const obj = /** @type {object} */ (_.get(config, section, config)); // section is allowed to be `undefined` + + const mappedObj = schema + ? _.mapKeys( + obj, + (__, prop) => + schema.properties[prop]?.appiumCliDest ?? _.camelCase(prop), + ) + : _.mapKeys(obj, (__, prop) => _.camelCase(prop)); + + return _.mapValues(mappedObj, (value, property) => { + const nextSection = section ? `${section}.${property}` : property; + return isSchemaTypeObject(value) ? normalize(config, nextSection) : value; + }); + }; + + /** + * Returns `true` if the schema prop references an object, or if it's an object itself + * @param {import('ajv').SchemaObject|object} schema - Referencing schema object + */ + const isSchemaTypeObject = (schema) => Boolean(schema.properties); + + return normalize(config); +} + +/** + * Result of calling {@link readConfigFile}. + * @typedef {Object} ReadConfigFileResult + * @property {import('ajv').ErrorObject[]} [errors] - Validation errors + * @property {string} [filepath] - The path to the config file, if found + * @property {boolean} [isEmpty] - If `true`, the config file exists but is empty + * @property {AppiumConfiguration|import('../types/types').NormalizedAppiumConfiguration} [config] - The parsed configuration + * @property {string|import('@sidvind/better-ajv-errors').IOutputError[]} [reason] - Human-readable error messages and suggestions. If the `pretty` option is `true`, this will be a nice string to print. + */ + +/** + * Options for {@link readConfigFile}. + * @typedef {Object} ReadConfigFileOptions + * @property {boolean} [pretty=true] If `false`, do not use color and fancy formatting in the `reason` property of the {@link ReadConfigFileResult}. The value of `reason` is then suitable for machine-reading. + * @property {boolean} [normalize=true] If `false`, do not normalize key names to camel case. + */ + +/** + * This is an `AsyncSearcher` which is inexplicably _not_ exported by the `lilconfig` type definition. + * @private + * @typedef {ReturnType} LilconfigAsyncSearcher + */ + +/** + * The contents of an Appium config file. Generated from schema + * @typedef {import('../types/types').AppiumConfiguration} AppiumConfiguration + */ + +/** + * The contents of an Appium config file with camelcased property names (and using `appiumCliDest` value if present). Generated from {@link AppiumConfiguration} + * @typedef {import('../types/types').NormalizedAppiumConfiguration} NormalizedAppiumConfiguration + */ + +/** + * The string should be a raw JSON string. + * @typedef {string} RawJson + */ + +/** + * Options for {@link formatConfigErrors}. + * @typedef {Object} FormatConfigErrorsOptions + * @property {import('./config-file').RawJson} [json] - Raw JSON config (as string) + * @property {boolean} [pretty=true] - Whether to format errors as a CLI-friendly string + */ diff --git a/packages/appium/lib/config.js b/packages/appium/lib/config.js index 216260e80..ea1980e03 100644 --- a/packages/appium/lib/config.js +++ b/packages/appium/lib/config.js @@ -9,6 +9,7 @@ import { StoreDeprecatedDefaultCapabilityAction, DEFAULT_CAPS_ARG, } from './cli/argparse-actions'; import findUp from 'find-up'; +import { getDefaultsFromSchema } from './schema'; const npmPackage = fs.readPackageJsonFrom(__dirname); @@ -156,9 +157,74 @@ async function showConfig () { console.log(JSON.stringify(getBuildInfo())); // eslint-disable-line no-console } -function getNonDefaultArgs (parser, args) { - return parser.rawArgs.reduce((acc, [, {dest, default: defaultValue}]) => { - if (args[dest] && args[dest] !== defaultValue) { +function getNonDefaultServerArgs (parser, args) { + // hopefully these function names are descriptive enough + + function typesDiffer (dest) { + return typeof args[dest] !== typeof defaultsFromSchema[dest]; + } + + function defaultValueIsArray (dest) { + return _.isArray(defaultsFromSchema[dest]); + } + + function argsValueIsArray (dest) { + return _.isArray(args[dest]); + } + + function arraysDiffer (dest) { + return _.difference(args[dest], defaultsFromSchema[dest]).length > 0; + } + + function valuesUnequal (dest) { + return args[dest] !== defaultsFromSchema[dest]; + } + + function defaultIsDefined (dest) { + return !_.isUndefined(defaultsFromSchema[dest]); + } + + // note that `_.overEvery` is like an "AND", and `_.overSome` is like an "OR" + + const argValueNotArrayOrArraysDiffer = _.overSome([ + _.negate(argsValueIsArray), + arraysDiffer + ]); + + const defaultValueNotArrayAndValuesUnequal = _.overEvery([ + _.negate(defaultValueIsArray), valuesUnequal + ]); + + /** + * This used to be a hideous conditional, but it's broken up into a hideous function instead. + * hopefully this makes things a little more understandable. + * - checks if the default value is defined + * - if so, and the default is not an array: + * - ensures the types are the same + * - ensures the values are equal + * - if so, and the default is an array: + * - ensures the args value is an array + * - ensures the args values do not differ from the default values + * @param {string} dest - argument name (`dest` value) + * @returns {boolean} + */ + const isNotDefault = _.overEvery([ + defaultIsDefined, + _.overSome([ + typesDiffer, + _.overEvery([ + defaultValueIsArray, + argValueNotArrayOrArraysDiffer + ]), + defaultValueNotArrayAndValuesUnequal + ]) + ]); + + // this is a merge of top-level defaults and server defaults. + const defaultsFromSchema = getDefaultsFromSchema(); + + return parser.rawArgs.reduce((acc, [, {dest}]) => { + if (isNotDefault(dest)) { acc[dest] = args[dest]; } return acc; @@ -182,56 +248,6 @@ function getDeprecatedArgs (parser, args) { }, {}); } -function checkValidPort (port, portName) { - if (port > 0 && port < 65536) return true; // eslint-disable-line curly - logger.error(`Port '${portName}' must be greater than 0 and less than 65536. Currently ${port}`); - return false; -} - -function validateServerArgs (parser, args) { - // arguments that cannot both be set - let exclusives = [ - ['noReset', 'fullReset'], - ['ipa', 'safari'], - ['app', 'safari'], - ['forceIphone', 'forceIpad'], - ['deviceName', 'defaultDevice'] - ]; - - for (let exSet of exclusives) { - let numFoundInArgs = 0; - for (let opt of exSet) { - if (_.has(args, opt) && args[opt]) { - numFoundInArgs++; - } - } - if (numFoundInArgs > 1) { - throw new Error(`You can't pass in more than one argument from the ` + - `set ${JSON.stringify(exSet)}, since they are ` + - `mutually exclusive`); - } - } - - const validations = { - port: checkValidPort, - callbackPort: checkValidPort, - bootstrapPort: checkValidPort, - chromedriverPort: checkValidPort, - robotPort: checkValidPort, - backendRetries: (r) => r >= 0, - }; - - const nonDefaultArgs = getNonDefaultArgs(parser, args); - - for (let [arg, validator] of _.toPairs(validations)) { - if (_.has(nonDefaultArgs, arg)) { - if (!validator(args[arg], arg)) { - throw new Error(`Invalid argument for param ${arg}: ${args[arg]}`); - } - } - } -} - async function validateTmpDir (tmpDir) { try { await mkdirp(tmpDir); @@ -242,8 +258,8 @@ async function validateTmpDir (tmpDir) { } export { - getBuildInfo, validateServerArgs, checkNodeOk, showConfig, - warnNodeDeprecations, validateTmpDir, getNonDefaultArgs, - getGitRev, checkValidPort, APPIUM_VER, updateBuildInfo, - getDeprecatedArgs, + getBuildInfo, checkNodeOk, showConfig, + warnNodeDeprecations, validateTmpDir, getNonDefaultServerArgs, + getGitRev, APPIUM_VER, updateBuildInfo, + getDeprecatedArgs }; diff --git a/packages/appium/lib/driver-config.js b/packages/appium/lib/driver-config.js index 8a97a6745..ddc6f0428 100644 --- a/packages/appium/lib/driver-config.js +++ b/packages/appium/lib/driver-config.js @@ -1,25 +1,52 @@ +// @ts-check + import _ from 'lodash'; import ExtensionConfig, { DRIVER_TYPE } from './extension-config'; export default class DriverConfig extends ExtensionConfig { - constructor (appiumHome, logFn = null) { + /** + * + * @param {string} appiumHome + * @param {(...args: any[]) => void} [logFn] + */ + constructor (appiumHome, logFn) { super(appiumHome, DRIVER_TYPE, logFn); + /** @type {Set} */ + this.knownAutomationNames = new Set(); } - getConfigProblems (driver) { + async read () { + this.knownAutomationNames.clear(); + return await super.read(); + } + + /** + * + * @param {object} extData + * @param {string} extName + * @returns {import('./extension-config').Problem[]} + */ + // eslint-disable-next-line no-unused-vars + getConfigProblems (extData, extName) { const problems = []; - const automationNames = []; - const {platformNames, automationName} = driver; + const {platformNames, automationName} = extData; if (!_.isArray(platformNames)) { problems.push({ - err: 'Missing or incorrect supported platformName list.', + err: 'Missing or incorrect supported platformNames list.', val: platformNames }); } else { - for (const pName of platformNames) { - if (!_.isString(pName)) { - problems.push({err: 'Incorrectly formatted platformName.', val: pName}); + if (_.isEmpty(platformNames)) { + problems.push({ + err: 'Empty platformNames list.', + val: platformNames + }); + } else { + for (const pName of platformNames) { + if (!_.isString(pName)) { + problems.push({err: 'Incorrectly formatted platformName.', val: pName}); + } } } } @@ -28,17 +55,23 @@ export default class DriverConfig extends ExtensionConfig { problems.push({err: 'Missing or incorrect automationName', val: automationName}); } - if (_.includes(automationNames, automationName)) { + if (this.knownAutomationNames.has(automationName)) { problems.push({ err: 'Multiple drivers claim support for the same automationName', val: automationName }); } - automationNames.push(automationName); + + // should we retain the name at the end of this function, once we've checked there are no problems? + this.knownAutomationNames.add(automationName); return problems; } + /** + * @param {string} driverName + * @param {object} extData + */ extensionDesc (driverName, {version, automationName}) { return `${driverName}@${version} (automationName '${automationName}')`; } diff --git a/packages/appium/lib/ext-config-io.js b/packages/appium/lib/ext-config-io.js new file mode 100644 index 000000000..00b0567cb --- /dev/null +++ b/packages/appium/lib/ext-config-io.js @@ -0,0 +1,230 @@ +// @ts-check + +/** + * Module containing {@link ExtConfigIO} which handles reading & writing of extension config files. + */ + +import _ from 'lodash'; +import { fs, mkdirp } from '@appium/support'; +import path from 'path'; +import YAML from 'yaml'; + +const CONFIG_FILE_NAME = 'extensions.yaml'; +const CONFIG_SCHEMA_REV = 2; + +export const DRIVER_TYPE = 'driver'; +export const PLUGIN_TYPE = 'plugin'; + +const VALID_EXT_TYPES = new Set([DRIVER_TYPE, PLUGIN_TYPE]); + +const CONFIG_DATA_DRIVER_KEY = `${DRIVER_TYPE}s`; +const CONFIG_DATA_PLUGIN_KEY = `${PLUGIN_TYPE}s`; + +/** + * Handles reading & writing of extension config files. + * + * Only one instance of this class exists per value of `APPIUM_HOME`. + */ +class ExtConfigIO { + /** + * "Dirty" flag. If true, the data has changed since the last write. + * @type {boolean} + * @private + */ + _dirty; + /** + * The entire contents of a parsed YAML extension config file. + * @type {object?} + * @private + */ + _data; + + /** + * A mapping of extension type to configuration data. Configuration data is keyed on extension name. + * + * Consumers get the values of this `Map` and do not have access to the entire data object. + * @type {Map<'driver'|'plugin',object>} + * @private + */ + _extensionTypeData = new Map(); + + /** + * Path to config file. + * @type {Readonly} + */ + _filepath; + + /** + * Path to `APPIUM_HOME` + * @type {Readonly} + */ + _appiumHome; + + /** + * @param {string} appiumHome + */ + constructor (appiumHome) { + this._filepath = path.resolve(appiumHome, CONFIG_FILE_NAME); + this._appiumHome = appiumHome; + } + + /** + * Creaes a proxy which watches for changes to the extension-type-specific config data. + * @param {'driver'|'plugin'} extensionType + * @param {Record} data - Extension config data, keyed by name + * @private + * @returns {Record} + */ + _createProxy (extensionType, data) { + return new Proxy(data[`${extensionType}s`], { + set: (target, prop, value) => { + if (value !== target[prop]) { + this._dirty = true; + } + target[prop] = value; + return Reflect.set(target, prop, value); + }, + deleteProperty: (target, prop) => { + if (prop in target) { + this._dirty = true; + } + return Reflect.deleteProperty(target, prop); + }, + }); + } + + /** + * Returns the path to the config file. This value is intended to be read-only. + */ + get filepath () { + return this._filepath; + } + + /** + * Gets data for an extension type. Reads the config file if necessary. + * + * Force-reading is _not_ supported, as it's likely to be a source of + * bugs--it's easy to mutate the data and then overwrite memory with the file + * contents + * @param {'driver'|'plugin'} extensionType - Which bit of the config data we + * want + * @returns {Promise} The data + */ + async read (extensionType) { + if (!VALID_EXT_TYPES.has(extensionType)) { + throw new TypeError(`Invalid extension type: ${extensionType}. Valid values are: ${[...VALID_EXT_TYPES].join(', ')}`); + } + if (this._extensionTypeData.has(extensionType)) { + return this._extensionTypeData.get(extensionType); + } + + let data; + let isNewFile = false; + try { + await mkdirp(this._appiumHome); + const yaml = await fs.readFile(this.filepath, 'utf8'); + data = this._applySchemaMigrations(YAML.parse(yaml)); + } catch (err) { + if (err.code === 'ENOENT') { + data = { + [CONFIG_DATA_DRIVER_KEY]: {}, + [CONFIG_DATA_PLUGIN_KEY]: {}, + schemaRev: CONFIG_SCHEMA_REV, + }; + isNewFile = true; + } else { + throw new Error( + `Appium had trouble loading the extension installation ` + + `cache file (${this.filepath}). Ensure it exists and is ` + + `readable. Specific error: ${err.message}`, + ); + } + } + + this._data = data; + this._extensionTypeData.set( + DRIVER_TYPE, + this._createProxy(DRIVER_TYPE, data), + ); + this._extensionTypeData.set( + PLUGIN_TYPE, + this._createProxy(PLUGIN_TYPE, data), + ); + + if (isNewFile) { + await this.write(true); + } + + return this._extensionTypeData.get(extensionType); + } + + /** + * Writes the data if it needs writing. + * + * If the `schemaRev` prop needs updating, the file will be written. + * @param {boolean} [force=false] - Whether to force a write even if the data is clean + * @returns {Promise} Whether the data was written + */ + async write (force = false) { + if (!this._dirty && !force) { + return false; + } + + if (!this._data) { + throw new ReferenceError('No data to write. Call `read()` first'); + } + + const dataToWrite = { + ...this._data, + [CONFIG_DATA_DRIVER_KEY]: this._extensionTypeData.get(DRIVER_TYPE), + [CONFIG_DATA_PLUGIN_KEY]: this._extensionTypeData.get(PLUGIN_TYPE), + }; + + try { + await fs.writeFile(this.filepath, YAML.stringify(dataToWrite), 'utf8'); + } catch { + throw new Error( + `Appium could not parse or write from the Appium Home directory ` + + `(${this._appiumHome}). Please ensure it is writable.`, + ); + } + + this._dirty = false; + return true; + } + + /** + * Normalizes the file, even if it was created with `schemaRev` < 2 + * At schema revision 2, we started including plugins as well as drivers in the file, + * so make sure we at least have an empty section for it. + * Returns a shallow copy of `yamlData`. + * @param {Readonly} yamlData - Parsed contents of YAML `extensions.yaml` + * @private + * @returns {object} A shallow copy of `yamlData` + */ + _applySchemaMigrations (yamlData) { + if (yamlData.schemaRev < 2 && yamlData[CONFIG_DATA_PLUGIN_KEY] === undefined) { + this._dirty = true; + return {...yamlData, [CONFIG_DATA_PLUGIN_KEY]: {}, schemaRev: 2}; + } + return {...yamlData}; + } +} + +/** + * Factory function for {@link ExtConfigIO}. + * + * Maintains one instance per value of `APPIUM_HOME`. + * @param {string} appiumHome - `APPIUM_HOME` + * @returns {ExtConfigIO} + */ +export const getExtConfigIOInstance = _.memoize((appiumHome) => new ExtConfigIO(appiumHome)); + +/** + * @typedef {ExtConfigIO} ExtensionConfigIO + */ + + +/** + * @typedef {typeof DRIVER_TYPE | typeof PLUGIN_TYPE} ExtensionType + */ diff --git a/packages/appium/lib/extension-config.js b/packages/appium/lib/extension-config.js index 937942d11..909ff282f 100644 --- a/packages/appium/lib/extension-config.js +++ b/packages/appium/lib/extension-config.js @@ -1,16 +1,14 @@ +// @ts-check + import _ from 'lodash'; -import log from './logger'; -import { fs, mkdirp } from '@appium/support'; -import path from 'path'; import os from 'os'; -import YAML from 'yaml'; +import path from 'path'; +import { getExtConfigIOInstance } from './ext-config-io'; +import log from './logger'; +import { ALLOWED_SCHEMA_EXTENSIONS, readExtensionSchema } from './schema'; -const DRIVER_TYPE = 'driver'; -const PLUGIN_TYPE = 'plugin'; const DEFAULT_APPIUM_HOME = path.resolve(os.homedir(), '.appium'); - -const CONFIG_FILE_NAME = 'extensions.yaml'; -const CONFIG_SCHEMA_REV = 2; +const APPIUM_HOME = process.env.APPIUM_HOME || DEFAULT_APPIUM_HOME; const INSTALL_TYPE_NPM = 'npm'; const INSTALL_TYPE_LOCAL = 'local'; @@ -23,27 +21,42 @@ const INSTALL_TYPES = [ INSTALL_TYPE_NPM ]; - export default class ExtensionConfig { - constructor (appiumHome, extensionType, logFn = null) { - if (logFn === null) { + /** + * + * @param {string} appiumHome - `APPIUM_HOME` + * @param {ExtensionType} extensionType - Type of extension + * @param {(...args: any[]) => void} [logFn] + */ + constructor (appiumHome, extensionType, logFn) { + if (!_.isFunction(logFn)) { logFn = log.error.bind(log); } this.appiumHome = appiumHome; - this.configFile = path.resolve(this.appiumHome, CONFIG_FILE_NAME); + /** + * @type {Record} + */ this.installedExtensions = {}; + this.io = getExtConfigIOInstance(appiumHome); this.extensionType = extensionType; + /** @type {'drivers'|'plugins'} */ this.configKey = `${extensionType}s`; - this.yamlData = {[`${DRIVER_TYPE}s`]: {}, [`${PLUGIN_TYPE}s`]: {}}; - this.log = logFn; + this.log = /** @type {(...args: any[])=>void} */(logFn); } + /** + * Checks extensions for problems + * @template ExtData + * @param {ExtData[]} exts - Array of extData objects + * @returns {ExtData[]} + */ validate (exts) { const foundProblems = {}; for (const [extName, extData] of _.toPairs(exts)) { foundProblems[extName] = [ - ...this.getGenericConfigProblems(extData), - ...this.getConfigProblems(extData) + ...this.getGenericConfigProblems(extData, extName), + ...this.getConfigProblems(extData, extName), + ...this.getSchemaProblems(extData, extName) ]; } @@ -64,7 +77,7 @@ export default class ExtensionConfig { if (!_.isEmpty(problemSummaries)) { this.log(`Appium encountered one or more errors while validating ` + - `the ${this.configKey} extension file (${this.configFile}):`); + `the ${this.configKey} extension file (${this.io.filepath}):`); for (const summary of problemSummaries) { this.log(summary); } @@ -73,8 +86,47 @@ export default class ExtensionConfig { return exts; } - getGenericConfigProblems (ext) { - const {version, pkgName, installSpec, installType, installPath, mainClass} = ext; + /** + * @param {object} extData + * @param {string} extName + * @returns {Problem[]} + */ + getSchemaProblems (extData, extName) { + const problems = []; + const {schema: argSchemaPath} = extData; + if (!_.isUndefined(argSchemaPath)) { + if (!_.isString(argSchemaPath)) { + problems.push({ + err: 'Incorrectly formatted schema field; must be a path to a schema file.', + val: argSchemaPath + }); + } else { + const argSchemaPathFileExtName = path.extname(argSchemaPath); + if (!ALLOWED_SCHEMA_EXTENSIONS.has(argSchemaPathFileExtName)) { + problems.push({ + err: `Schema file has unsupported extension. Allowed: ${[...ALLOWED_SCHEMA_EXTENSIONS].join(', ')}`, + val: argSchemaPath + }); + } else { + try { + readExtensionSchema(this.extensionType, extName, extData); + } catch (err) { + problems.push({err: `Unable to register schema at path ${argSchemaPath}`, val: argSchemaPath}); + } + } + } + } + return problems; + } + + /** + * @param {object} extData + * @param {string} extName + * @returns {Problem[]} + */ + // eslint-disable-next-line no-unused-vars + getGenericConfigProblems (extData, extName) { + const {version, pkgName, installSpec, installType, installPath, mainClass} = extData; const problems = []; if (!_.isString(version)) { @@ -104,62 +156,46 @@ export default class ExtensionConfig { return problems; } - getConfigProblems (/*ext*/) { + /** + * @param {object} extData + * @param {string} extName + * @returns {Problem[]} + */ + // eslint-disable-next-line no-unused-vars + getConfigProblems (extData, extName) { // shoud override this method if special validation is necessary for this extension type return []; } - applySchemaMigrations () { - if (this.yamlData.schemaRev < 2 && _.isUndefined(this.yamlData[PLUGIN_TYPE])) { - // at schema revision 2, we started including plugins as well as drivers in the file, - // so make sure we at least have an empty section for it - this.yamlData[PLUGIN_TYPE] = {}; - } - } - + /** + * @returns {Promise} + */ async read () { - await mkdirp(this.appiumHome); // ensure appium home exists - try { - this.yamlData = YAML.parse(await fs.readFile(this.configFile, 'utf8')); - this.applySchemaMigrations(); - - // set the list of drivers the user has installed - this.installedExtensions = this.validate(this.yamlData[this.configKey]); - } catch (err) { - if (await fs.exists(this.configFile)) { - // if the file exists and we couldn't parse it, that's a problem - throw new Error(`Appium had trouble loading the extension installation ` + - `cache file (${this.configFile}). Ensure it exists and is ` + - `readable. Specific error: ${err.message}`); - } - - // if the config file doesn't exist, try to write an empty one, to make - // sure we actually have write privileges, and complain if we don't - try { - await this.write(); - } catch { - throw new Error(`Appium could not read or write from the Appium Home directory ` + - `(${this.appiumHome}). Please ensure it is writable.`); - } - } - return this.installedExtensions; + return (this.installedExtensions = await this.io.read(this.extensionType)); } - + /** + * @returns {Promise} + */ async write () { - const newYamlData = { - ...this.yamlData, - schemaRev: CONFIG_SCHEMA_REV, - [this.configKey]: this.installedExtensions - }; - await fs.writeFile(this.configFile, YAML.stringify(newYamlData), 'utf8'); + return await this.io.write(); } + /** + * @param {string} extName + * @param {object} extData + * @returns {Promise} + */ async addExtension (extName, extData) { this.installedExtensions[extName] = extData; await this.write(); } + /** + * @param {string} extName + * @param {object} extData + * @returns {Promise} + */ async updateExtension (extName, extData) { this.installedExtensions[extName] = { ...this.installedExtensions[extName], @@ -168,6 +204,10 @@ export default class ExtensionConfig { await this.write(); } + /** + * @param {string} extName + * @returns {Promise} + */ async removeExtension (extName) { delete this.installedExtensions[extName]; await this.write(); @@ -187,20 +227,41 @@ export default class ExtensionConfig { } } - extensionDesc () { - throw new Error('This must be implemented in a final class'); + /** + * Returns a string describing the extension. Subclasses must implement. + * @param {string} extName - Extension name + * @param {object} extData - Extension data + * @returns {string} + * @abstract + */ + // eslint-disable-next-line no-unused-vars + extensionDesc (extName, extData) { + throw new Error('This must be implemented in a subclass'); } + /** + * @param {string} extName + * @returns {string} + */ getExtensionRequirePath (extName) { const {pkgName, installPath} = this.installedExtensions[extName]; return path.resolve(this.appiumHome, installPath, 'node_modules', pkgName); } + /** + * @param {string} extName + * @returns {string} + */ getInstallPath (extName) { const {installPath} = this.installedExtensions[extName]; return path.resolve(this.appiumHome, installPath); } + /** + * Loads extension and returns its main class + * @param {string} extName + * @returns {(...args: any[]) => object } + */ require (extName) { const {mainClass} = this.installedExtensions[extName]; const reqPath = this.getExtensionRequirePath(extName); @@ -212,12 +273,30 @@ export default class ExtensionConfig { return require(reqPath)[mainClass]; } + /** + * @param {string} extName + * @returns {boolean} + */ isInstalled (extName) { return _.includes(Object.keys(this.installedExtensions), extName); } } + +/** + * Config problem + * @typedef {Object} Problem + * @property {string} err - Error message + * @property {any} val - Associated value + */ + +/** + * Alias + * @typedef {import('./ext-config-io').ExtensionType} ExtensionType + */ + +export { DRIVER_TYPE, PLUGIN_TYPE } from './ext-config-io'; export { INSTALL_TYPE_NPM, INSTALL_TYPE_GIT, INSTALL_TYPE_LOCAL, INSTALL_TYPE_GITHUB, - INSTALL_TYPES, DEFAULT_APPIUM_HOME, DRIVER_TYPE, PLUGIN_TYPE, + INSTALL_TYPES, DEFAULT_APPIUM_HOME, APPIUM_HOME }; diff --git a/packages/appium/lib/grid-register.js b/packages/appium/lib/grid-register.js index 9767f9fea..317316ee6 100644 --- a/packages/appium/lib/grid-register.js +++ b/packages/appium/lib/grid-register.js @@ -9,20 +9,31 @@ const hubUri = (config) => { return `${protocol}://${config.hubHost}:${config.hubPort}`; }; -async function registerNode (configFile, addr, port, basePath) { - let data; - try { - data = await fs.readFile(configFile, 'utf-8'); - } catch (err) { - logger.error(`Unable to load node configuration file to register with grid: ${err.message}`); - return; +/** + * Registers a new node with a selenium grid + * @param {string|object} data - Path or object representing selenium grid node config file + * @param {string} addr - Bind to this address + * @param {number} port - Bind to this port + * @param {string} basePath - Base path for the grid + */ +async function registerNode (data, addr, port, basePath) { + let configFilePath; + if (typeof data === 'string') { + configFilePath = data; + try { + data = await fs.readFile(data, 'utf-8'); + } catch (err) { + logger.error(`Unable to load node configuration file ${configFilePath} to register with grid: ${err.message}`); + return; + } + try { + data = JSON.parse(data); + } catch (err) { + logger.errorAndThrow(`Syntax error in node configuration file ${configFilePath}: ${err.message}`); + return; + } } - // Check presence of data before posting it to the selenium grid - if (!data) { - logger.error('No data found in the node configuration file to send to the grid'); - return; - } postRequest(data, addr, port, basePath); } @@ -39,15 +50,7 @@ async function registerToGrid (postOptions, configHolder) { } } -function postRequest (data, addr, port, basePath) { - // parse json to get hub host and port - let configHolder; - try { - configHolder = JSON.parse(data); - } catch (err) { - logger.errorAndThrow(`Syntax error in node configuration file: ${err.message}`); - } - +function postRequest (configHolder, addr, port, basePath) { // Move Selenium 3 configuration properties to configuration object if (!_.has(configHolder, 'configuration')) { let configuration = {}; diff --git a/packages/appium/lib/main.js b/packages/appium/lib/main.js index fc605b87e..3feb6f215 100755 --- a/packages/appium/lib/main.js +++ b/packages/appium/lib/main.js @@ -1,29 +1,24 @@ #!/usr/bin/env node // transpile:main -import { init as logsinkInit } from './logsink'; -import logger from './logger'; // logger needs to remain first of imports -import _ from 'lodash'; -import { server as baseServer, routeConfiguringFunction as makeRouter } from '@appium/base-driver'; -import { asyncify } from 'asyncbox'; -import { default as getParser, getDefaultServerArgs } from './cli/parser'; -import { USE_ALL_PLUGINS } from './cli/args'; +import { routeConfiguringFunction as makeRouter, server as baseServer } from '@appium/base-driver'; import { logger as logFactory, util } from '@appium/support'; -import { - showConfig, checkNodeOk, validateServerArgs, - warnNodeDeprecations, validateTmpDir, getNonDefaultArgs, - getGitRev, APPIUM_VER -} from './config'; -import DriverConfig from './driver-config'; -import PluginConfig from './plugin-config'; -import { DRIVER_TYPE, PLUGIN_TYPE } from './extension-config'; -import { runExtensionCommand } from './cli/extension'; +import { asyncify } from 'asyncbox'; +import _ from 'lodash'; import { AppiumDriver } from './appium'; +import { driverConfig, pluginConfig, USE_ALL_PLUGINS } from './cli/args'; +import { runExtensionCommand } from './cli/extension'; +import { default as getParser } from './cli/parser'; +import { APPIUM_VER, checkNodeOk, getGitRev, getNonDefaultServerArgs, showConfig, validateTmpDir, warnNodeDeprecations } from './config'; +import { readConfigFile } from './config-file'; +import { DRIVER_TYPE, PLUGIN_TYPE } from './extension-config'; import registerNode from './grid-register'; +import logger from './logger'; // logger needs to remain first of imports +import { init as logsinkInit } from './logsink'; +import { getDefaultsFromSchema, validate } from './schema'; import { inspectObject } from './utils'; - -async function preflightChecks ({parser, args, driverConfig, pluginConfig, throwInsteadOfExit = false}) { +async function preflightChecks ({args, throwInsteadOfExit = false}) { try { checkNodeOk(); if (args.longStacktrace) { @@ -34,9 +29,9 @@ async function preflightChecks ({parser, args, driverConfig, pluginConfig, throw process.exit(0); } warnNodeDeprecations(); - validateServerArgs(parser, args); - await driverConfig.read(); - await pluginConfig.read(); + + validate(args); + if (args.tmpDir) { await validateTmpDir(args.tmpDir); } @@ -69,7 +64,7 @@ async function logStartupInfo (parser, args) { } logger.info(welcome); - let showArgs = getNonDefaultArgs(parser, args); + let showArgs = getNonDefaultServerArgs(parser, args); if (_.size(showArgs)) { logNonDefaultArgsWarning(showArgs); } @@ -100,7 +95,7 @@ function logServerPort (address, port) { */ function getActivePlugins (args, pluginConfig) { return Object.keys(pluginConfig.installedExtensions).filter((pluginName) => - _.includes(args.plugins, pluginName) || + _.includes(args.plugins ?? [], pluginName) || (args.plugins.length === 1 && args.plugins[0] === USE_ALL_PLUGINS) ).map((pluginName) => { try { @@ -152,14 +147,22 @@ function getExtraMethodMap (driverClasses, pluginClasses) { ); } -async function main (args = null) { - let parser = getParser(); +/** + * Initializes Appium, but does not start the server. + * + * Use this to get at the configuration schema. + * + * @example + * import {init, getSchema} from 'appium'; + * const options = {}; // config object + * await init(options); + * const schema = getSchema(); // entire config schema including plugins and drivers + * @returns {Promise<{parser: import('argparse').ArgumentParser, appiumDriver?: AppiumDriver}>} + */ +async function init (args = null) { + const parser = await getParser(); let throwInsteadOfExit = false; if (args) { - // a containing package passed in their own args, let's fill them out - // with defaults - args = Object.assign({}, getDefaultServerArgs(), args); - // if we have a containing package instead of running as a CLI process, // that package might not appreciate us calling 'process.exit' willy- // nilly, so give it the option to have us throw instead of exit @@ -172,13 +175,39 @@ async function main (args = null) { // otherwise parse from CLI args = parser.parse_args(); } + + const configResult = await readConfigFile(args.configFile); + + if (!_.isEmpty(configResult.errors)) { + throw new Error(`Errors in config file ${configResult.filepath}:\n ${configResult.reason ?? configResult.errors}`); + } + + // merge config and apply defaults. + // the order of precendece is: + // 1. command line args + // 2. config file + // 3. defaults from config file. + // if no "subcommand" specified (e.g., `args` came from not-`parser.parse_args()`), assume we want a server. + if (args.subcommand === 'server' || !args.subcommand) { + args = _.defaultsDeep( + args, + configResult.config?.server, + getDefaultsFromSchema() + ); + } + + args = _.defaultsDeep( + args, + configResult.config ?? {}, + ); + await logsinkInit(args); // if the user has requested the 'driver' CLI, don't run the normal server, // but instead pass control to the driver CLI if (args.subcommand === DRIVER_TYPE || args.subcommand === PLUGIN_TYPE) { - await runExtensionCommand(args, args.subcommand); - process.exit(); + await runExtensionCommand(args, args.subcommand, driverConfig); + return {parser}; } if (args.logFilters) { @@ -194,15 +223,26 @@ async function main (args = null) { } } - let appiumDriver = new AppiumDriver(args); - const driverConfig = new DriverConfig(args.appiumHome); + + const appiumDriver = new AppiumDriver(args); // set the config on the umbrella driver so it can match drivers to caps appiumDriver.driverConfig = driverConfig; - const pluginConfig = new PluginConfig(args.appiumHome); await preflightChecks({parser, args, driverConfig, pluginConfig, throwInsteadOfExit}); + + return {parser, appiumDriver}; +} + +async function main (args = null) { + const {parser, appiumDriver} = await init(args); + + if (!appiumDriver) { + return; + } + const pluginClasses = getActivePlugins(args, pluginConfig); // set the active plugins on the umbrella driver so it can use them for commands appiumDriver.pluginClasses = pluginClasses; + await logStartupInfo(parser, args); let routeConfiguringFunction = makeRouter(appiumDriver); @@ -240,7 +280,8 @@ async function main (args = null) { appiumDriver.server = server; try { // configure as node on grid, if necessary - if (args.nodeconfig !== null) { + // falsy values should not cause this to run + if (args.nodeconfig) { await registerNode(args.nodeconfig, args.address, args.port, args.basePath); } } catch (err) { @@ -276,4 +317,8 @@ if (require.main === module) { asyncify(main); } -export { main }; +// everything below here is intended to be a public API. +export { main, init }; +export { APPIUM_HOME } from './extension-config'; +export { getSchema, getValidator, finalizeSchema as finalize } from './schema'; +export { readConfigFile, validateConfig } from './config-file'; diff --git a/packages/appium/lib/schema.js b/packages/appium/lib/schema.js new file mode 100644 index 000000000..10b200636 --- /dev/null +++ b/packages/appium/lib/schema.js @@ -0,0 +1,321 @@ +// @ts-check + +import path from 'path'; +import resolveFrom from 'resolve-from'; +import {APPIUM_HOME} from './extension-config'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import _ from 'lodash'; +import appiumConfigSchema from './appium-config-schema'; + +export const ALLOWED_SCHEMA_EXTENSIONS = new Set(['.json', '.js', '.cjs']); + +/** + * Singleton Ajv instance. A single instance can manage multiple schemas + */ +const ajv = addFormats( + new Ajv({ + // without this not much validation actually happens + allErrors: true, + // enables use to use `"type": ["foo", "bar"]` in schema + allowUnionTypes: true, + // enables us to use custom properties (e.g., `appiumCliDest`); see `AppiumSchemaMetadata` + strict: false, + }), +); + +/** + * The original ID of the Appium config schema. + * We use this in the CLI to convert it to `argparse` options. + */ +export const APPIUM_CONFIG_SCHEMA_ID = 'appium.json'; + +/** + * Registers a schema from an extension. + * + * This is "fail-fast" in that the schema will immediately be validated against JSON schema draft-07 _or_ whatever the value of the schema's `$schema` prop is. + * + * Does _not_ add the schema to the `ajv` instance (this is done by {@link finalizeSchema}). + * @param {import('./ext-config-io').ExtensionType} extType - Extension type + * @param {string} extName - Unique extension name for `type` + * @param {SchemaObject} schema - Schema object + * @returns {void} + */ +export function registerSchema (extType, extName, schema) { + if (!(extType && extName && !_.isEmpty(schema))) { + throw new TypeError( + 'Expected nonempty extension type, extension name and schema parameters', + ); + } + if (!registeredSchemas.has(extType)) { + registeredSchemas.set(extType, new Map()); + } + const schemasForType = /** @type {Map}*/ ( + registeredSchemas.get(extType) + ); + if (schemasForType.has(extName)) { + throw new Error( + `Name for ${extType} schema "${extName}" conflicts with an existing schema`, + ); + } + ajv.validateSchema(schema, true); + extName = _.camelCase(extName); + schemasForType.set(extName, schema); +} + +/** + * Map of {@link ExtensionType} to extension names to schemas. + * + * Used to hold schemas in memory until {@link resetSchema} is called. + * @type {Map>} + */ +let registeredSchemas = new Map(); + +/** + * Returns `true` if the extension has registered a schema. + * @param {'driver'|'plugin'} extensionType + * @param {string} extensionName + * @returns {boolean} + */ +export function hasRegisteredSchema (extensionType, extensionName) { + return ( + registeredSchemas.has(extensionType) && + /** @type {Map} */ ( + registeredSchemas.get(extensionType) + ).has(extensionName) + ); +} + +/** + * Checks if schema has been finalized. + * @returns {boolean} `true` if {@link finalizeSchema} has been called successfully. + */ +export function isFinalized () { + return Boolean(ajv.getSchema(APPIUM_CONFIG_SCHEMA_ID)); +} + +/** + * After all potential schemas have been registered, combine and finalize the schema, then add it to the ajv instance. + * + * If the schema has already been finalized, this is a no-op. + * @public + * @throws {Error} If the schema is not valid + * @returns {void} + */ +export function finalizeSchema () { + if (isFinalized()) { + return; + } + /** + * For all schemas within a particular extension type, combine into an object + * to be inserted under the `.properties` key of the base + * schema. + * @param {Map} extensionSchemas + * @returns {Record} + */ + const combineExtSchemas = (extensionSchemas) => + _.reduce( + _.fromPairs([...extensionSchemas]), + (extensionTypeSchema, extSchema, name) => ({ + ...extensionTypeSchema, + [name]: {...extSchema, additionalProperties: false}, + }), + {}, + ); + + // Ajv will _mutate_ the schema, so we need to clone it. + const baseSchema = _.cloneDeep(appiumConfigSchema); + const finalSchema = _.reduce( + _.fromPairs([...registeredSchemas]), + (baseSchema, extensionSchemas, extensionType) => { + baseSchema.properties[extensionType].properties = + combineExtSchemas(extensionSchemas); + return baseSchema; + }, + baseSchema, + ); + + ajv.addSchema(finalSchema, APPIUM_CONFIG_SCHEMA_ID); + ajv.validateSchema(finalSchema, true); +} + +/** + * Resets the registered schemas and the ajv instance. Resets all memoized functions. + * + * If you need to call {@link finalizeSchema} again, you'll want to call this first. + * @public + * @returns {void} + */ +export function resetSchema () { + ajv.removeSchema(APPIUM_CONFIG_SCHEMA_ID); + registeredSchemas = new Map(); + flattenSchema.cache = new Map(); + readExtensionSchema.cache = new Map(); +} + +/** + * Given an object, validates it against the Appium config schema. + * If errors occur, the returned array will be non-empty. + * @param {any} value - The value (hopefully an object) to validate against the schema + * @public + * @returns {import('ajv').ErrorObject[]} Array of errors, if any. + */ +export function validate (value) { + const validator = /** @type {import('ajv').ValidateFunction} */ ( + getValidator() + ); + return !validator(value) && _.isArray(validator.errors) + ? [...validator.errors] + : []; +} + +/** + * Retrieves schema validator function + * @public + * @returns {import('ajv').ValidateFunction} + */ +export function getValidator () { + const validator = ajv.getSchema(APPIUM_CONFIG_SCHEMA_ID); + if (!validator) { + throw new Error('Schema not yet compiled!'); + } + return validator; +} + +/** + * Gets a formatter by name; useful for validation outside of schema contexts. + * + * A "formatter" is essentially just a validation function, but is more granular than a simple "type" (e.g., `string`); matches against `RegExp`s, numeric ranges, etc. + * @param {string} name + * @returns {AjvFormatter} + */ +export function getFormatter (name) { + const formatter = /** @type {AjvFormatter|undefined} */ (ajv.formats[name]); + if (!formatter) { + throw new ReferenceError(`Unknown formatter "${name}"`); + } + return formatter; +} + +/** + * Retrieves the schema itself + * @public + * @returns {SchemaObject} + */ +export function getSchema () { + return /** @type {SchemaObject} */ (getValidator().schema); +} + +/** + * Get defaults from the schema. Returns object with keys matching the camel-cased + * value of `appiumCliDest` (see schema) or the key name (camel-cased). + * If no default found, the property will not have an associated key in the returned object. + * @returns {Record} + */ +export function getDefaultsFromSchema () { + return _.omitBy(_.mapValues(flattenSchema(), 'default'), _.isUndefined); +} + +/** + * Flatten schema into a k/v pair of property names and `SchemaObject`s. + * + * Converts nested extension schemas to keys based on the extension type and + * name. Used when translating to `argparse` options or getting the list of + * default values (see {@link getDefaultsFromSchema}) for CLI or otherwise. + * + * Memoized until {@link resetSchema} is called. + * @throws If {@link finalizeSchema} has not been called yet. + * @returns {Record} + */ +export const flattenSchema = _.memoize(() => { + const schema = getSchema(); + + /** + * + * @param {string} key + * @param {string[]} [prefix] + * @returns + */ + const normalizeKey = (key, prefix) => { + key = _.camelCase(key); + if (prefix?.length) { + key = [...prefix, key].join('-'); + } + return key; + }; + + /** @type {{props: SchemaObject, prefix: string[], ref: string}[]} */ + const stack = [{props: schema.properties, prefix: [], ref: '#/properties'}]; + /** @type {SchemaObject} */ + const flattened = {}; + + // this bit is a recursive algorithm rewritten as a for loop. + // when we find something we want to traverse, we add it to `stack` + for (const {props, prefix, ref} of stack) { + const pairs = _.toPairs(props); + for (const [key, value] of pairs) { + if (value.properties) { + stack.push({ + props: value.properties, + prefix: key === 'server' ? [] : [...prefix, key], + ref: `#${ref}/${key}/properties`, + }); + } else { + const newKey = normalizeKey(value.appiumCliDest ?? key, prefix); + flattened[newKey] = value; + } + } + } + + return flattened; +}); + +/** + * Given an `ExtensionConfig`, read a schema from disk and register it. + * @param {import('./ext-config-io').ExtensionType} type + * @param {string} extName - Extension name (unique to its type) + * @param {ExtData} extData - Extension config + * @returns {SchemaObject|undefined} + */ +export const readExtensionSchema = _.memoize( + (type, extName, extData) => { + const {installPath, pkgName, schema: argSchemaPath} = extData; + if (!installPath || !pkgName || !argSchemaPath || !extName) { + throw new TypeError('Incomplete extension data'); + } + if (argSchemaPath) { + const schemaPath = resolveFrom( + path.resolve(APPIUM_HOME, installPath), + // this path sep is fine because `resolveFrom` uses Node's module resolution + path.normalize(`${pkgName}/${argSchemaPath}`), + ); + const moduleObject = require(schemaPath); + // this sucks. default exports should be destroyed + const schema = moduleObject.__esModule + ? moduleObject.default + : moduleObject; + registerSchema(type, extName, schema); + return schema; + } + }, + (type, extData, extName) => `${type}-${extName}`, +); + +/** + * Alias + * @typedef {import('ajv').SchemaObject} SchemaObject + */ + +/** + * There is some disagreement between these types and the type of the values in + * {@link ajv.formats}, which is why we need this. I guess. + * @typedef {import('ajv/dist/types').FormatValidator|import('ajv/dist/types').FormatDefinition|import('ajv/dist/types').FormatValidator|import('ajv/dist/types').FormatDefinition|RegExp} AjvFormatter +*/ + +/** + * Extension data (pulled from config YAML) + * @typedef {Object} ExtData + * @property {string} [schema] - Optional schema path if the ext defined it + * @property {string} pkgName - Package name + * @property {string} installPath - Actually looks more like a module identifier? Resolved from `APPIUM_HOME` + */ diff --git a/packages/appium/lib/utils.js b/packages/appium/lib/utils.js index d0246527f..73fa2ac1f 100644 --- a/packages/appium/lib/utils.js +++ b/packages/appium/lib/utils.js @@ -33,23 +33,6 @@ function inspectObject (args) { } } -/** - * Given a set of CLI args and the name of a driver or plugin, extract those args for that plugin - * @param {Object} extensionArgs - arguments of the form {[extName]: {[argName]: [argValue]}} - * @param {string} extensionName - the name of the extension - * @return {Object} the arg object for that extension alone - */ -function getExtensionArgs (extensionArgs, extensionName) { - if (!_.has(extensionArgs, extensionName)) { - return {}; - } - if (!_.isPlainObject(extensionArgs[extensionName])) { - throw new TypeError(`Driver or plugin arguments must be plain objects`); - } - return extensionArgs[extensionName]; -} - - /** * Given a set of args and a set of constraints, throw an error if any args are not mentioned in * the set of constraints @@ -72,6 +55,7 @@ function ensureNoUnknownArgs (extensionArgs, argsConstraints) { * * @param {object} extensionArgs - Driver or Plugin specific args * @param {object} argsConstraints - Constraints for arguments + * @deprecated Extensions should use a schema instead of providing `argsConstraints` * @throws {Error} if any args are not recognized or are of an invalid type */ function validateExtensionArgs (extensionArgs, argsConstraints) { @@ -269,6 +253,6 @@ const rootDir = fs.findRoot(__dirname); export { inspectObject, parseCapsForInnerDriver, insertAppiumPrefixes, rootDir, - getPackageVersion, pullSettings, removeAppiumPrefixes, getExtensionArgs, + getPackageVersion, pullSettings, removeAppiumPrefixes, validateExtensionArgs }; diff --git a/packages/appium/package.json b/packages/appium/package.json index b6ab4e0c0..e501e6d8e 100644 --- a/packages/appium/package.json +++ b/packages/appium/package.json @@ -32,7 +32,8 @@ "bin", "lib", "build/lib", - "postinstall.js" + "postinstall.js", + "types" ], "scripts": { "generate-docs": "gulp transpile && node ./build/commands-yml/parse.js", @@ -47,6 +48,10 @@ "@appium/base-plugin": "1.7.2", "@appium/support": "^2.55.0", "@babel/runtime": "7.16.0", + "@oclif/errors": "^1.3.5", + "@sidvind/better-ajv-errors": "0.9.0", + "ajv": "8.6.2", + "ajv-formats": "2.1.0", "argparse": "2.0.1", "async-lock": "1.3.0", "asyncbox": "2.9.2", @@ -54,6 +59,7 @@ "bluebird": "3.7.2", "continuation-local-storage": "3.2.1", "find-up": "5.0.0", + "lilconfig": "2.0.3", "lodash": "4.17.21", "longjohn": "0.2.12", "npmlog": "5.0.1", diff --git a/packages/appium/test/cli-e2e-specs.js b/packages/appium/test/cli/cli-e2e-specs.js similarity index 78% rename from packages/appium/test/cli-e2e-specs.js rename to packages/appium/test/cli/cli-e2e-specs.js index 16cff8299..9cb8849c1 100644 --- a/packages/appium/test/cli-e2e-specs.js +++ b/packages/appium/test/cli/cli-e2e-specs.js @@ -2,13 +2,12 @@ import path from 'path'; import { exec } from 'teen_process'; import { tempDir, fs, mkdirp, util } from '@appium/support'; -import { KNOWN_DRIVERS } from '../lib/drivers'; -import { PROJECT_ROOT as cwd } from './helpers'; +import { KNOWN_DRIVERS } from '../../lib/drivers'; +import { PROJECT_ROOT } from '../helpers'; -// cannot use `require.resolve()` here (w/o acrobatics) due to the ESM context. -// could also derive it from the `package.json` if we wanted -const executable = path.join(cwd, 'packages', 'appium', 'build', 'lib', 'main.js'); +// the ESM `main.js` is not executable as-is +const executable = path.join(PROJECT_ROOT, 'packages', 'appium', 'build', 'lib', 'main.js'); describe('CLI', function () { let appiumHome; @@ -27,15 +26,26 @@ describe('CLI', function () { } async function run (driverCmd, args = [], raw = false, ext = 'driver') { - args = [...args, '--appium-home', appiumHome]; - const ret = await exec('node', [executable, ext, driverCmd, ...args], {cwd}); - if (raw) { - return ret; + try { + const ret = await exec(process.execPath, [executable, ext, driverCmd, ...args], { + cwd: PROJECT_ROOT, + env: { + APPIUM_HOME: appiumHome, + PATH: process.env.PATH + } + }); + if (raw) { + return ret; + } + return ret.stdout; + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + throw err; } - return ret.stdout; } describe('Driver CLI', function () { - const localFakeDriverPath = path.resolve(__dirname, '..', '..', '..', 'fake-driver'); + const localFakeDriverPath = path.join(PROJECT_ROOT, 'packages', 'fake-driver'); describe('list', function () { it('should list available drivers', async function () { const stdout = await run('list'); @@ -55,9 +65,9 @@ describe('CLI', function () { }); it('should show updates for installed drivers with --updates', async function () { await clear(); - await run('install', ['appium-fake-driver@0.9.0', '--source', 'npm', '--json']); + await run('install', ['@appium/fake-driver@3.0.4', '--source', 'npm', '--json']); const {fake} = JSON.parse(await run('list', ['--updates', '--json'])); - util.compareVersions(fake.updateVersion, '>', '0.9.0').should.be.true; + util.compareVersions(fake.updateVersion, '>', '3.0.4').should.be.true; const stdout = await run('list', ['--updates']); stdout.should.match(new RegExp(`fake.+[${fake.updateVersion} available]`)); }); @@ -76,20 +86,20 @@ describe('CLI', function () { }); it('should install a driver from npm', async function () { await clear(); - const ret = JSON.parse(await run('install', ['appium-fake-driver', '--source', 'npm', '--json'])); - ret.fake.pkgName.should.eql('appium-fake-driver'); + const ret = JSON.parse(await run('install', ['@appium/fake-driver', '--source', 'npm', '--json'])); + ret.fake.pkgName.should.eql('@appium/fake-driver'); ret.fake.installType.should.eql('npm'); - ret.fake.installSpec.should.eql('appium-fake-driver'); + ret.fake.installSpec.should.eql('@appium/fake-driver'); const list = JSON.parse(await run('list', ['--installed', '--json'])); delete list.fake.installed; list.should.eql(ret); }); it('should install a driver from npm with a specific version/tag', async function () { await clear(); - const ret = JSON.parse(await run('install', ['appium-fake-driver@0.9.0', '--source', 'npm', '--json'])); - ret.fake.pkgName.should.eql('appium-fake-driver'); + const ret = JSON.parse(await run('install', ['@appium/fake-driver@3.0.5', '--source', 'npm', '--json'])); + ret.fake.pkgName.should.eql('@appium/fake-driver'); ret.fake.installType.should.eql('npm'); - ret.fake.installSpec.should.eql('appium-fake-driver@0.9.0'); + ret.fake.installSpec.should.eql('@appium/fake-driver@3.0.5'); const list = JSON.parse(await run('list', ['--installed', '--json'])); delete list.fake.installed; list.should.eql(ret); @@ -105,7 +115,18 @@ describe('CLI', function () { delete list.fake.installed; list.should.eql(ret); }); - it('should install a driver from git', async function () { + it('should install a driver from a local git repo', async function () { + await clear(); + const ret = JSON.parse(await run('install', [localFakeDriverPath, + '--source', 'git', '--package', '@appium/fake-driver', '--json'])); + ret.fake.pkgName.should.eql('@appium/fake-driver'); + ret.fake.installType.should.eql('git'); + ret.fake.installSpec.should.eql(localFakeDriverPath); + const list = JSON.parse(await run('list', ['--installed', '--json'])); + delete list.fake.installed; + list.should.eql(ret); + }); + it('should install a driver from a remote git repo', async function () { await clear(); const ret = JSON.parse(await run('install', ['git+https://github.com/appium/appium-fake-driver.git', '--source', 'git', '--package', 'appium-fake-driver', '--json'])); @@ -133,7 +154,7 @@ describe('CLI', function () { describe('uninstall', function () { it('should uninstall a driver based on its driver name', async function () { await clear(); - const ret = JSON.parse(await run('install', ['appium-fake-driver', '--source', 'npm', '--json'])); + const ret = JSON.parse(await run('install', ['@appium/fake-driver', '--source', 'npm', '--json'])); const installPath = path.resolve(appiumHome, ret.fake.installPath); await fs.exists(installPath).should.eventually.be.true; let list = JSON.parse(await run('list', ['--installed', '--json'])); @@ -172,7 +193,7 @@ describe('CLI', function () { }); describe('Plugin CLI', function () { - const fakePluginDir = path.resolve(__dirname, '..', '..', '..', '..', 'node_modules', '@appium', 'fake-plugin'); + const fakePluginDir = path.dirname(require.resolve('@appium/fake-plugin/package.json')); const ext = 'plugin'; describe('run', function () { before(async function () { diff --git a/packages/appium/test/cli-specs.js b/packages/appium/test/cli/cli-specs.js similarity index 89% rename from packages/appium/test/cli-specs.js rename to packages/appium/test/cli/cli-specs.js index 6d83ed101..c893b31a8 100644 --- a/packages/appium/test/cli-specs.js +++ b/packages/appium/test/cli/cli-specs.js @@ -1,15 +1,15 @@ // transpile:mocha -import { DEFAULT_APPIUM_HOME } from '../lib/extension-config'; -import DriverConfig from '../lib/driver-config'; -import DriverCommand from '../lib/cli/driver-command'; +import { DEFAULT_APPIUM_HOME } from '../../lib/extension-config'; +import DriverConfig from '../../lib/driver-config'; +import DriverCommand from '../../lib/cli/driver-command'; import sinon from 'sinon'; describe('DriverCommand', function () { const config = new DriverConfig(DEFAULT_APPIUM_HOME); const driver = 'fake'; - const pkgName = 'appium-fake-driver'; + const pkgName = '@appium/fake-driver'; config.installedExtensions = {[driver]: {version: '1.0.0', pkgName}}; const dc = new DriverCommand({config, json: true}); diff --git a/packages/appium/test/cli/schema-args-specs.js b/packages/appium/test/cli/schema-args-specs.js new file mode 100644 index 000000000..819b4c89c --- /dev/null +++ b/packages/appium/test/cli/schema-args-specs.js @@ -0,0 +1,42 @@ +import {rewiremock} from '../helpers'; +import sinon from 'sinon'; +import { finalizeSchema, resetSchema } from '../../lib/schema'; + +const expect = chai.expect; + +describe('cli/schema-args', function () { + /** @type {import('../../lib/cli/schema-args')} */ + let schemaArgs; + + /** + * @type {import('sinon').SinonSandbox} + */ + let sandbox; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + schemaArgs = rewiremock.proxy(() => require('../../lib/cli/schema-args')); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('toParserArgs()', function () { + describe('when called with no parameters', function () { + beforeEach(finalizeSchema); + + afterEach(resetSchema); + + it('should return an array', function () { + expect(schemaArgs.toParserArgs()).to.be.an('array').that.is.not.empty; + }); + }); + + describe('when schema has not yet been compiled', function () { + it('should throw', function () { + expect(() => schemaArgs.toParserArgs()).to.throw('Schema not yet compiled'); + }); + }); + }); +}); diff --git a/packages/appium/test/config-file-e2e-specs.js b/packages/appium/test/config-file-e2e-specs.js new file mode 100644 index 000000000..2006856fc --- /dev/null +++ b/packages/appium/test/config-file-e2e-specs.js @@ -0,0 +1,267 @@ +import path from 'path'; +import {logger} from '@appium/support'; +import {readConfigFile} from '../lib/config-file'; +import {registerSchema, finalizeSchema, resetSchema} from '../lib/schema'; +import extSchema from './fixtures/driver.schema.js'; + +describe('config file behavior', function () { + const FIXTURE_PATH = path.join(__dirname, './fixtures/config'); + + const GOOD_FILEPATH = path.join(FIXTURE_PATH, 'appium.config.good.json'); + const NODECONFIG_FILEPATH = path.join( + FIXTURE_PATH, + 'appium.config.nodeconfig-path.json', + ); + const BAD_FILEPATH = path.join(FIXTURE_PATH, 'appium.config.bad.json'); + const INVALID_JSON_FILEPATH = path.join( + FIXTURE_PATH, + 'appium.config.invalid.json', + ); + const SECURITY_ARRAY_FILEPATH = path.join( + FIXTURE_PATH, + 'appium.config.security-array.json', + ); + const SECURITY_DELIMITED_FILEPATH = path.join( + FIXTURE_PATH, + 'appium.config.security-delimited.json', + ); + const SECURITY_PATH_FILEPATH = path.join( + FIXTURE_PATH, + 'appium.config.security-path.json', + ); + const UNKNOWN_PROPS_FILEPATH = path.join( + FIXTURE_PATH, + 'appium.config.ext-unknown-props.json', + ); + + let oldLogLevel; + before(function () { + // canonical way to do this? + oldLogLevel = logger.getLogger('Appium').level; + logger.getLogger('Appium').level = 'error'; + }); + + beforeEach(function () { + finalizeSchema(); + }); + + afterEach(function () { + resetSchema(); + }); + + after(function () { + logger.getLogger('Appium').level = oldLogLevel; + }); + + describe('when provided a path to a config file', function () { + describe('when the config file is valid per the schema', function () { + it('should return a valid config object', async function () { + const result = await readConfigFile(GOOD_FILEPATH, { + normalize: false, + }); + result.should.deep.equal({ + config: require(GOOD_FILEPATH), + filepath: GOOD_FILEPATH, + errors: [], + }); + }); + + describe('server.nodeconfig behavior', function () { + describe('when a string', function () { + it('should return a valid config object', async function () { + const result = await readConfigFile(NODECONFIG_FILEPATH, { + normalize: false, + }); + result.should.deep.equal({ + config: require(NODECONFIG_FILEPATH), + filepath: NODECONFIG_FILEPATH, + errors: [], + }); + }); + }); + }); + + describe('server.allow-insecure behavior', function () { + describe('when a string path', function () { + it('should return a valid config object', async function () { + const result = await readConfigFile(SECURITY_PATH_FILEPATH, { + normalize: false, + }); + result.should.deep.equal({ + config: require(SECURITY_PATH_FILEPATH), + filepath: SECURITY_PATH_FILEPATH, + errors: [], + }); + }); + }); + + describe('when a comma-delimited string', function () { + it('should return a valid config object', async function () { + const result = await readConfigFile(SECURITY_DELIMITED_FILEPATH, { + normalize: false, + }); + result.should.deep.equal({ + config: require(SECURITY_DELIMITED_FILEPATH), + filepath: SECURITY_DELIMITED_FILEPATH, + errors: [], + }); + }); + }); + + describe('when an array', function () { + it('should return a valid config object', async function () { + const result = await readConfigFile(SECURITY_ARRAY_FILEPATH, { + normalize: false, + }); + result.should.deep.equal({ + config: require(SECURITY_ARRAY_FILEPATH), + filepath: SECURITY_ARRAY_FILEPATH, + errors: [], + }); + }); + }); + }); + }); + + describe('when the config file is invalid per the schema', function () { + describe('without extensions', function () { + it('should return an object containing errors', async function () { + const result = await readConfigFile(BAD_FILEPATH, { + normalize: false, + }); + result.should.have.deep.property('config', require(BAD_FILEPATH)); + result.should.have.property('filepath', BAD_FILEPATH); + result.should.have.deep.property('errors', [ + { + instancePath: '', + schemaPath: '#/additionalProperties', + keyword: 'additionalProperties', + params: { + additionalProperty: 'appium-home', + }, + message: 'must NOT have additional properties', + isIdentifierLocation: true, + }, + { + instancePath: '/server/allow-cors', + schemaPath: '#/properties/server/properties/allow-cors/type', + keyword: 'type', + params: { + type: 'boolean', + }, + message: 'must be boolean', + }, + { + instancePath: '/server/allow-insecure', + schemaPath: '#/properties/server/properties/allow-insecure/type', + keyword: 'type', + params: { + type: ['array', 'string'], + }, + message: 'must be array,string', + }, + { + instancePath: '/server/callback-port', + schemaPath: + '#/properties/server/properties/callback-port/maximum', + keyword: 'maximum', + params: { + comparison: '<=', + limit: 65535, + }, + message: 'must be <= 65535', + }, + { + instancePath: '/server/log-level', + schemaPath: '#/properties/server/properties/log-level/enum', + keyword: 'enum', + params: { + allowedValues: [ + 'info', + 'info:debug', + 'info:info', + 'info:warn', + 'info:error', + 'warn', + 'warn:debug', + 'warn:info', + 'warn:warn', + 'warn:error', + 'error', + 'error:debug', + 'error:info', + 'error:warn', + 'error:error', + 'debug', + 'debug:debug', + 'debug:info', + 'debug:warn', + 'debug:error', + ], + }, + message: 'must be equal to one of the allowed values', + }, + { + instancePath: '/server/log-no-colors', + schemaPath: '#/properties/server/properties/log-no-colors/type', + keyword: 'type', + params: { + type: 'boolean', + }, + message: 'must be boolean', + }, + { + instancePath: '/server/port', + schemaPath: '#/properties/server/properties/port/type', + keyword: 'type', + params: { + type: 'integer', + }, + message: 'must be integer', + }, + ]); + + result.should.have.property('reason').that.is.a.string; + }); + }); + + describe('with extensions', function () { + beforeEach(function () { + resetSchema(); + registerSchema('driver', 'fake', extSchema); + finalizeSchema(); + }); + + describe('when provided a config file with unknown properties', function () { + let result; + beforeEach(async function () { + result = await readConfigFile(UNKNOWN_PROPS_FILEPATH, { + normalize: false, + }); + }); + it('should return an object containing errors', function () { + result.errors.should.eql([ + { + instancePath: '/driver/fake', + schemaPath: + '#/properties/driver/properties/fake/additionalProperties', + keyword: 'additionalProperties', + params: {additionalProperty: 'bubb'}, + message: 'must NOT have additional properties', + isIdentifierLocation: true, + }, + ]); + }); + }); + }); + }); + + describe('when the config file is invalid JSON', function () { + it('should reject with a user-friendly error message', async function () { + await readConfigFile(INVALID_JSON_FILEPATH).should.be.rejectedWith( + new RegExp(`${INVALID_JSON_FILEPATH} is invalid`), + ); + }); + }); + }); +}); diff --git a/packages/appium/test/config-file-specs.js b/packages/appium/test/config-file-specs.js new file mode 100644 index 000000000..e4f11ccc3 --- /dev/null +++ b/packages/appium/test/config-file-specs.js @@ -0,0 +1,388 @@ +// @ts-check + +import {rewiremock, resolveFixture} from './helpers'; +import YAML from 'yaml'; +import fs from 'fs'; +import sinon from 'sinon'; +import * as schema from '../lib/schema'; + +const expect = chai.expect; + +describe('config-file', function () { + const GOOD_YAML_CONFIG_FILEPATH = resolveFixture( + 'config', + 'appium.config.good.yaml', + ); + const GOOD_JSON_CONFIG_FILEPATH = resolveFixture( + 'config', + 'appium.config.good.json', + ); + const GOOD_YAML_CONFIG = YAML.parse( + fs.readFileSync(GOOD_YAML_CONFIG_FILEPATH, 'utf8'), + ); + const GOOD_JSON_CONFIG = require(GOOD_JSON_CONFIG_FILEPATH); + const BAD_JSON_CONFIG_FILEPATH = resolveFixture( + 'config', + 'appium.config.bad.json', + ); + const BAD_JSON_CONFIG = require(BAD_JSON_CONFIG_FILEPATH); + + /** + * @type {import('sinon').SinonSandbox} + */ + let sandbox; + + /** + * `readConfigFile()` from an isolated `config-file` module + * @type {typeof import('../lib/config-file').readConfigFile} + */ + let readConfigFile; + /** + * Mock instance of `lilconfig` containing stubs for + * `lilconfig#search()` and `lilconfig#load`. + * Not _actually_ a `SinonStubbedInstance`, but duck-typed. + * @type {import('sinon').SinonStubbedInstance>} + */ + let lc; + + /** + * @type {typeof import('../lib/config-file')} + */ + let configFileModule; + + let mocks; + + before(function () { + // generally called via the CLI parser, this needs to be done manually in tests. + // we don't need to do this before _each_ test, because we're not changing the schema. + // if we did change the schema, this would need ot be in `beforeEach()` and `afterEach()` + // would need to call `schema.reset()`. + schema.finalizeSchema(); + }); + + beforeEach(function () { + sandbox = sinon.createSandbox(); + + // we have to manually type this (and `search()`) because we'd only get the real type + // when stubbing an object prop; e.g., `stub(lilconfig, 'load')` + const load = /** @type {AsyncSearcherLoadStub} */ ( + sandbox.stub().resolves({ + config: GOOD_JSON_CONFIG, + filepath: GOOD_JSON_CONFIG_FILEPATH, + }) + ); + load.withArgs(GOOD_YAML_CONFIG_FILEPATH).resolves({ + config: GOOD_YAML_CONFIG, + filepath: GOOD_YAML_CONFIG_FILEPATH, + }); + load.withArgs(BAD_JSON_CONFIG_FILEPATH).resolves({ + config: BAD_JSON_CONFIG, + filepath: BAD_JSON_CONFIG_FILEPATH, + }); + + const search = /** @type {AsyncSearcherLoadStub} */ ( + sandbox.stub().resolves({ + config: GOOD_JSON_CONFIG, + filepath: GOOD_JSON_CONFIG_FILEPATH, + }) + ); + + lc = { + load, + search, + }; + + mocks = { + lilconfig: { + lilconfig: sandbox.stub().returns(lc), + }, + '@sidvind/better-ajv-errors': sandbox.stub().returns(''), + }; + + // loads the `config-file` module using the lilconfig mock. + // we only mock lilconfig because it'd otherwise be a pain in the rear to test + // searching for config files, and it increases the likelihood that we'd load the wrong file. + configFileModule = rewiremock.proxy(() => require('../lib/config-file'), mocks); + readConfigFile = configFileModule.readConfigFile; + + // just want to be extra-sure `validate()` happens + sandbox.spy(schema, 'validate'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('readConfigFile()', function () { + /** + * @type {import('../lib/config-file').ReadConfigFileResult} + */ + let result; + + it('should support yaml', async function () { + const {config} = await readConfigFile(GOOD_YAML_CONFIG_FILEPATH); + expect(config).to.eql(require(GOOD_JSON_CONFIG_FILEPATH)); + }); + + describe('when no filepath provided', function () { + beforeEach(async function () { + result = await readConfigFile(); + }); + + it('should search for a config file', function () { + expect(lc.search).to.have.been.calledOnce; + }); + + it('should not try to load a config file directly', function () { + expect(lc.load).to.not.have.been.called; + }); + + describe('when no config file is found', function () { + beforeEach(async function () { + lc.search.resolves(); + result = await readConfigFile(); + }); + + it('should resolve with an empty object', function () { + expect(result).to.be.an('object').that.is.empty; + }); + }); + + describe('when a config file is found', function () { + describe('when the config file is empty', function () { + beforeEach(async function () { + lc.search.resolves({ + isEmpty: true, + filepath: '/path/to/file.json', + config: {}, + }); + + result = await readConfigFile(); + }); + + it('should resolve with an object with an `isEmpty` property', function () { + expect(result).to.have.property('isEmpty', true); + }); + }); + + describe('when the config file is not empty', function () { + it('should validate the config against a schema', function () { + expect(schema.validate).to.have.been.calledOnceWith( + GOOD_JSON_CONFIG, + ); + }); + + describe('when the config file is valid', function () { + beforeEach(async function () { + result = await readConfigFile(); + }); + + it('should resolve with an object having `config` property and empty array of errors', function () { + expect(result).to.deep.equal({ + config: GOOD_JSON_CONFIG, + errors: [], + filepath: GOOD_JSON_CONFIG_FILEPATH, + }); + }); + }); + + describe('when the config file is invalid', function () { + beforeEach(function () { + lc.search.resolves({ + config: {foo: 'bar'}, + filepath: '/path/to/file.json', + }); + }); + + beforeEach(async function () { + result = await readConfigFile(); + }); + + it('should resolve with an object having a nonempty array of errors', function () { + expect(result).to.have.property('errors').that.is.not.empty; + }); + }); + }); + }); + }); + + describe('when filepath provided', function () { + beforeEach(async function () { + result = await readConfigFile('appium.json'); + }); + + it('should not attempt to find a config file', function () { + expect(lc.search).to.not.have.been.called; + }); + + it('should try to load a config file directly', function () { + expect(lc.load).to.have.been.calledOnce; + }); + + describe('when no config file exists at path', function () { + beforeEach(function () { + lc.load.rejects(Object.assign(new Error(), {code: 'ENOENT'})); + }); + + it('should reject with user-friendly message', async function () { + await expect(readConfigFile('appium.json')).to.be.rejectedWith( + /not found at user-provided path/, + ); + }); + }); + + describe('when the config file is invalid JSON', function () { + beforeEach(function () { + lc.load.rejects(new SyntaxError()); + }); + + it('should reject with user-friendly message', async function () { + await expect(readConfigFile('appium.json')).to.be.rejectedWith( + /Config file at user-provided path appium.json is invalid/, + ); + }); + }); + + describe('when something else is wrong with loading the config file', function () { + beforeEach(function () { + lc.load.rejects(new Error('guru meditation')); + }); + + it('should pass error through', async function () { + await expect(readConfigFile('appium.json')).to.be.rejectedWith( + /guru meditation/, + ); + }); + }); + + describe('when a config file is found', function () { + describe('when the config file is empty', function () { + beforeEach(async function () { + lc.search.resolves({ + isEmpty: true, + filepath: '/path/to/file.json', + config: {}, + }); + result = await readConfigFile(); + }); + + it('should resolve with an object with an `isEmpty` property', function () { + expect(result).to.have.property('isEmpty', true); + }); + }); + + describe('when the config file is not empty', function () { + it('should validate the config against a schema', function () { + expect(schema.validate).to.have.been.calledOnceWith( + GOOD_JSON_CONFIG, + ); + }); + + describe('when the config file is valid', function () { + beforeEach(async function () { + result = await readConfigFile(); + }); + + it('should resolve with an object having `config` property and empty array of errors', function () { + expect(result).to.deep.equal({ + errors: [], + config: GOOD_JSON_CONFIG, + filepath: GOOD_JSON_CONFIG_FILEPATH, + }); + }); + }); + + describe('when the config file is invalid', function () { + beforeEach(async function () { + result = await readConfigFile(BAD_JSON_CONFIG_FILEPATH); + }); + + it('should resolve with an object having a nonempty array of errors', function () { + expect(result).to.have.property('errors').that.is.not.empty; + }); + }); + }); + }); + }); + }); + + describe('formatConfigErrors()', function () { + describe('when provided `errors` as an empty array', function () { + it('should throw', function () { + expect(() => configFileModule.formatConfigErrors([])).to.throw( + TypeError, + 'Array of errors must be non-empty', + ); + }); + }); + + describe('when provided `errors` as `undefined`', function () { + it('should throw', function () { + // @ts-ignore + expect(() => configFileModule.formatConfigErrors()).to.throw( + TypeError, + 'Array of errors must be non-empty', + ); + }); + }); + + describe('when provided `errors` as a non-empty array', function () { + it('should return a string', function () { + // @ts-ignore + expect(configFileModule.formatConfigErrors([{}])).to.be.a('string'); + }); + }); + + describe('when `opts.pretty` is `false`', function () { + it('should call `betterAjvErrors()` with option `format: "js"`', function () { + // @ts-ignore + configFileModule.formatConfigErrors([{}], {}, {pretty: false}); + expect(mocks['@sidvind/better-ajv-errors']).to.have.been.calledWith( + schema.getSchema(), + {}, + [{}], + {format: 'js', json: undefined}, + ); + }); + }); + + describe('when `opts.json` is a string', function () { + it('should call `betterAjvErrors()` with option `json: opts.json`', function () { + // @ts-ignore + configFileModule.formatConfigErrors([{}], {}, {json: '{"foo": "bar"}'}); + expect(mocks['@sidvind/better-ajv-errors']).to.have.been.calledWith( + schema.getSchema(), + {}, + [{}], + {format: 'cli', json: '{"foo": "bar"}'}, + ); + }); + }); + }); +}); + +// the following are just aliases + +/** + * @typedef {import('ajv').ErrorObject} ErrorObject + */ + +/** + * @typedef {import('ajv').ValidateFunction} ValidateFunction + */ + +/** + * @typedef {ReturnType["load"]} AsyncSearcherLoad + */ + +/** + * @typedef {ReturnType["search"]} AsyncSearcherSearch + */ + +/** + * @typedef {import('sinon').SinonStub,ReturnType>} AsyncSearcherLoadStub + */ + +/** + * @typedef {import('sinon').SinonStub,ReturnType>} AsyncSearcherSearchStub + */ diff --git a/packages/appium/test/config-specs.js b/packages/appium/test/config-specs.js index 835bf9b08..9e863b302 100644 --- a/packages/appium/test/config-specs.js +++ b/packages/appium/test/config-specs.js @@ -2,11 +2,10 @@ import _ from 'lodash'; import sinon from 'sinon'; -import { getBuildInfo, checkNodeOk, warnNodeDeprecations, - getNonDefaultArgs, validateServerArgs, - validateTmpDir, showConfig, checkValidPort } from '../lib/config'; import getParser from '../lib/cli/parser'; +import { checkNodeOk, getBuildInfo, getNonDefaultServerArgs, showConfig, validateTmpDir, warnNodeDeprecations } from '../lib/config'; import logger from '../lib/logger'; +import { getDefaultsFromSchema, resetSchema } from '../lib/schema'; describe('Config', function () { describe('Appium config', function () { @@ -91,45 +90,32 @@ describe('Config', function () { }); describe('server arguments', function () { - let parser = getParser(); - parser.debug = true; // throw instead of exit on error; pass as option instead? - let args = {}; - beforeEach(function () { - // give all the defaults - for (let rawArg of parser.rawArgs) { - args[rawArg[1].dest] = rawArg[1].default; - } + let parser; + let args; + + before(async function () { + parser = await getParser(); + parser.debug = true; }); - describe('getNonDefaultArgs', function () { + + beforeEach(function () { + // get all the defaults + args = getDefaultsFromSchema(); + }); + describe('getNonDefaultServerArgs', function () { it('should show none if we have all the defaults', function () { - let nonDefaultArgs = getNonDefaultArgs(parser, args); - _.keys(nonDefaultArgs).length.should.equal(0); + let nonDefaultArgs = getNonDefaultServerArgs(parser, args); + nonDefaultArgs.should.be.empty; }); it('should catch a non-default argument', function () { args.allowCors = true; - let nonDefaultArgs = getNonDefaultArgs(parser, args); - _.keys(nonDefaultArgs).length.should.equal(1); - should.exist(nonDefaultArgs.allowCors); + let nonDefaultArgs = getNonDefaultServerArgs(parser, args); + nonDefaultArgs.should.eql({allowCors: true}); }); }); }); - describe('checkValidPort', function () { - it('should be false for port too high', function () { - checkValidPort(65536).should.be.false; - }); - it('should be false for port too low', function () { - checkValidPort(0).should.be.false; - }); - it('should be true for port 1', function () { - checkValidPort(1).should.be.true; - }); - it('should be true for port 65535', function () { - checkValidPort(65535).should.be.true; - }); - }); - describe('validateTmpDir', function () { it('should fail to use a tmp dir with incorrect permissions', function () { validateTmpDir('/private/if_you_run_with_sudo_this_wont_fail').should.be.rejectedWith(/could not ensure/); @@ -149,130 +135,25 @@ describe('Config', function () { argv1 = process.argv[1]; }); + beforeEach(function () { + resetSchema(); + }); + after(function () { process.argv[1] = argv1; }); - it('should not fail if process.argv[1] is undefined', function () { + it('should not fail if process.argv[1] is undefined', async function () { process.argv[1] = undefined; - let args = getParser(); + let args = await getParser(); args.prog.should.be.equal('appium'); }); - it('should set "prog" to process.argv[1]', function () { + it('should set "prog" to process.argv[1]', async function () { process.argv[1] = 'Hello World'; - let args = getParser(); + let args = await getParser(); args.prog.should.be.equal('Hello World'); }); }); - describe('validateServerArgs', function () { - let parser = getParser(); - parser.debug = true; // throw instead of exit on error; pass as option instead? - const defaultArgs = {}; - // give all the defaults - for (let rawArg of parser.rawArgs) { - defaultArgs[rawArg[1].dest] = rawArg[1].default; - } - let args = {}; - beforeEach(function () { - args = _.clone(defaultArgs); - }); - describe('mutually exclusive server arguments', function () { - describe('noReset and fullReset', function () { - it('should not allow both', function () { - (() => { - args.noReset = args.fullReset = true; - validateServerArgs(parser, args); - }).should.throw(); - }); - it('should allow noReset', function () { - (() => { - args.noReset = true; - validateServerArgs(parser, args); - }).should.not.throw(); - }); - it('should allow fullReset', function () { - (() => { - args.fullReset = true; - validateServerArgs(parser, args); - }).should.not.throw(); - }); - }); - describe('ipa and safari', function () { - it('should not allow both', function () { - (() => { - args.ipa = args.safari = true; - validateServerArgs(parser, args); - }).should.throw(); - }); - it('should allow ipa', function () { - (() => { - args.ipa = true; - validateServerArgs(parser, args); - }).should.not.throw(); - }); - it('should allow safari', function () { - (() => { - args.safari = true; - validateServerArgs(parser, args); - }).should.not.throw(); - }); - }); - describe('app and safari', function () { - it('should not allow both', function () { - (() => { - args.app = args.safari = true; - validateServerArgs(parser, args); - }).should.throw(); - }); - it('should allow app', function () { - (() => { - args.app = true; - validateServerArgs(parser, args); - }).should.not.throw(); - }); - }); - describe('forceIphone and forceIpad', function () { - it('should not allow both', function () { - (() => { - args.forceIphone = args.forceIpad = true; - validateServerArgs(parser, args); - }).should.throw(); - }); - it('should allow forceIphone', function () { - (() => { - args.forceIphone = true; - validateServerArgs(parser, args); - }).should.not.throw(); - }); - it('should allow forceIpad', function () { - (() => { - args.forceIpad = true; - validateServerArgs(parser, args); - }).should.not.throw(); - }); - }); - describe('deviceName and defaultDevice', function () { - it('should not allow both', function () { - (() => { - args.deviceName = args.defaultDevice = true; - validateServerArgs(parser, args); - }).should.throw(); - }); - it('should allow deviceName', function () { - (() => { - args.deviceName = true; - validateServerArgs(parser, args); - }).should.not.throw(); - }); - it('should allow defaultDevice', function () { - (() => { - args.defaultDevice = true; - validateServerArgs(parser, args); - }).should.not.throw(); - }); - }); - }); - }); }); diff --git a/packages/appium/test/driver-e2e-specs.js b/packages/appium/test/driver-e2e-specs.js index 3bb12ea7e..688622a05 100644 --- a/packages/appium/test/driver-e2e-specs.js +++ b/packages/appium/test/driver-e2e-specs.js @@ -7,7 +7,7 @@ import axios from 'axios'; import { remote as wdio } from 'webdriverio'; import { main as appiumServer } from '../lib/main'; import { DEFAULT_APPIUM_HOME, INSTALL_TYPE_LOCAL, DRIVER_TYPE } from '../lib/extension-config'; -import { W3C_PREFIXED_CAPS, TEST_FAKE_APP, TEST_HOST, getTestPort} from './helpers'; +import { W3C_PREFIXED_CAPS, TEST_FAKE_APP, TEST_HOST, getTestPort, PROJECT_ROOT } from './helpers'; import { BaseDriver } from '@appium/base-driver'; import DriverConfig from '../lib/driver-config'; import { runExtensionCommand } from '../lib/cli/extension'; @@ -20,7 +20,7 @@ let TEST_PORT; const sillyWebServerPort = 1234; const sillyWebServerHost = 'hey'; const FAKE_ARGS = {sillyWebServerPort, sillyWebServerHost}; -const FAKE_DRIVER_ARGS = {fake: FAKE_ARGS}; +const FAKE_DRIVER_ARGS = {driver: {fake: FAKE_ARGS}}; const shouldStartServer = process.env.USE_RUNNING_SERVER !== '0'; const caps = W3C_PREFIXED_CAPS; const wdOpts = { @@ -36,7 +36,7 @@ describe('FakeDriver - via HTTP', function () { // actually going to be required by Appium let FakeDriver = null; let baseUrl; - const FAKE_DRIVER_DIR = path.resolve(__dirname, '..', '..', '..', 'fake-driver'); + const FAKE_DRIVER_DIR = path.join(PROJECT_ROOT, 'packages', 'fake-driver'); before(async function () { wdOpts.port = TEST_PORT = await getTestPort(); TEST_SERVER = `http://${TEST_HOST}:${TEST_PORT}`; @@ -104,8 +104,7 @@ describe('FakeDriver - via HTTP', function () { describe('cli args handling for passed in args', function () { before(async function () { await serverClose(); - const args = {driverArgs: FAKE_DRIVER_ARGS}; - await serverStart(args); + await serverStart(FAKE_DRIVER_ARGS); }); after(async function () { await serverClose(); diff --git a/packages/appium/test/driver-specs.js b/packages/appium/test/driver-specs.js index 2a766cbe0..f8f3ea1b4 100644 --- a/packages/appium/test/driver-specs.js +++ b/packages/appium/test/driver-specs.js @@ -4,6 +4,7 @@ import { AppiumDriver } from '../lib/appium'; import { BaseDriver } from '@appium/base-driver'; import { FakeDriver } from '@appium/fake-driver'; import { BASE_CAPS, W3C_CAPS, W3C_PREFIXED_CAPS } from './helpers'; +import {resetSchema} from '../lib/schema'; import _ from 'lodash'; import sinon from 'sinon'; import { sleep } from 'asyncbox'; @@ -13,6 +14,9 @@ import { insertAppiumPrefixes, removeAppiumPrefixes } from '../lib/utils'; const SESSION_ID = 1; describe('AppiumDriver', function () { + beforeEach(function () { + resetSchema(); + }); describe('AppiumDriver', function () { function getDriverAndFakeDriver (appiumArgs = {}, DriverClass = FakeDriver) { const appium = new AppiumDriver(appiumArgs); @@ -160,7 +164,7 @@ describe('AppiumDriver', function () { }); it('should error if you include driver args for a driver that doesnt define any', async function () { class NoArgsDriver {} - const args = {driverArgs: {fake: {webkitDebugProxyPort: 1234}}}; + const args = {driver: {fake: {webkitDebugProxyPort: 1234}}}; [appium, mockFakeDriver] = getDriverAndFakeDriver(args, NoArgsDriver); const {error} = await appium.createSession(undefined, undefined, W3C_CAPS); error.should.match(/does not define any/); @@ -175,7 +179,7 @@ describe('AppiumDriver', function () { }; } } - const args = {driverArgs: {fake: {diffArg: 1234}}}; + const args = {driver: {fake: {diffArg: 1234}}}; [appium, mockFakeDriver] = getDriverAndFakeDriver(args, DiffArgsDriver); const {error} = await appium.createSession(undefined, undefined, W3C_CAPS); error.should.match(/arguments were not recognized/); @@ -190,7 +194,7 @@ describe('AppiumDriver', function () { }; } } - const args = {driverArgs: {fake: {randomArg: 1234}}}; + const args = {driver: {fake: {randomArg: 1234}}}; [appium, mockFakeDriver] = getDriverAndFakeDriver(args, ArgsDriver); const {value} = await appium.createSession(undefined, undefined, W3C_CAPS); try { @@ -330,22 +334,22 @@ describe('AppiumDriver', function () { } }); it('should throw if a plugin arg is sent but the plugin doesnt support args', function () { - const appium = new AppiumDriver({pluginArgs: {noargs: {foo: 'bar'}}}); + const appium = new AppiumDriver({plugin: {noargs: {foo: 'bar'}}}); appium.pluginClasses = [NoArgsPlugin]; should.throw(() => appium.createPluginInstances(), /does not define any/); }); it('should throw if a plugin arg is sent but the plugin doesnt know about it', function () { - const appium = new AppiumDriver({pluginArgs: {args: {foo: 'bar'}}}); + const appium = new AppiumDriver({plugin: {args: {foo: 'bar'}}}); appium.pluginClasses = [ArgsPlugin]; should.throw(() => appium.createPluginInstances(), /arguments were not recognized/); }); it('should throw if a plugin arg is sent of the wrong type', function () { - const appium = new AppiumDriver({pluginArgs: {args: {randomArg: 'bar'}}}); + const appium = new AppiumDriver({plugin: {args: {randomArg: 'bar'}}}); appium.pluginClasses = [ArgsPlugin]; should.throw(() => appium.createPluginInstances(), /must be of type number/); }); it('should add cliArgs to the plugin once validated', function () { - const appium = new AppiumDriver({pluginArgs: {args: {randomArg: 1234}}}); + const appium = new AppiumDriver({plugin: {args: {randomArg: 1234}}}); appium.pluginClasses = [ArgsPlugin]; const plugin = appium.createPluginInstances()[0]; plugin.cliArgs.should.eql({randomArg: 1234}); diff --git a/packages/appium/test/ext-config-io-specs.js b/packages/appium/test/ext-config-io-specs.js new file mode 100644 index 000000000..1bd4df2e1 --- /dev/null +++ b/packages/appium/test/ext-config-io-specs.js @@ -0,0 +1,258 @@ +// @ts-check + +import {rewiremock} from './helpers'; +import path from 'path'; +import sinon from 'sinon'; +import {promises as fs} from 'fs'; +import YAML from 'yaml'; +const expect = chai.expect; + +describe('ExtensionConfigIO', function () { + /** + * @type {import('sinon').SinonSandbox} + */ + let sandbox; + + /** @type {string} */ + let yamlFixture; + + /** @type {string} */ + let yamlFixtureRev1; + + before(async function () { + yamlFixture = await fs.readFile( + path.join(__dirname, 'fixtures', 'extensions.yaml'), + 'utf8', + ); + yamlFixtureRev1 = await fs.readFile( + path.join(__dirname, 'fixtures', 'extensions-rev-1.yaml'), + 'utf8', + ); + }); + + /** + * @type {typeof import('../lib/ext-config-io').getExtConfigIOInstance} + */ + let getExtConfigIOInstance; + + let mocks; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + mocks = { + '@appium/support': { + fs: { + readFile: sandbox.stub().resolves(yamlFixture), + writeFile: sandbox.stub().resolves(true), + }, + mkdirp: sandbox.stub().resolves(), + }, + }; + getExtConfigIOInstance = rewiremock.proxy( + '../lib/ext-config-io', + mocks, + ).getExtConfigIOInstance; + }); + + afterEach(function () { + sandbox.restore(); + getExtConfigIOInstance.cache = new Map(); + }); + + describe('instantiation', function () { + describe('when called twice with the same `appiumHome` value', function () { + it('should return the same object both times', function () { + const firstInstance = getExtConfigIOInstance('/some/path'); + const secondInstance = getExtConfigIOInstance('/some/path'); + expect(firstInstance).to.equal(secondInstance); + }); + }); + + describe('when called twice with different `appiumHome` values', function () { + it('should return different objects', function () { + const firstInstance = getExtConfigIOInstance('/some/path'); + const secondInstance = getExtConfigIOInstance('/some/other/path'); + expect(firstInstance).to.not.equal(secondInstance); + }); + }); + }); + + describe('property', function () { + describe('filepath', function () { + it('should not be writable', function () { + const instance = getExtConfigIOInstance('/some/path'); + expect(() => { + // @ts-ignore + instance.filepath = '/some/other/path'; + }).to.throw(TypeError); + }); + }); + }); + + describe('read()', function () { + /** @type {import('../lib/ext-config-io').ExtensionConfigIO} */ + let io; + + beforeEach(function () { + io = getExtConfigIOInstance('/some/path'); + }); + + describe('when called with a valid extension type', function () { + describe('when the file does not exist', function () { + beforeEach(async function () { + /** @type {NodeJS.ErrnoException} */ + const err = new Error(); + err.code = 'ENOENT'; + mocks['@appium/support'].fs.readFile.rejects(err); + await io.read('driver'); + }); + + it('should create a new file', function () { + expect(mocks['@appium/support'].fs.writeFile).to.be.calledOnceWith( + io.filepath, + YAML.stringify({drivers: {}, plugins: {}, schemaRev: 2}), + 'utf8', + ); + }); + }); + + describe('when `schemaRev` is up-to-date', function () { + beforeEach(async function () { + await io.read('driver'); + }); + + it('should attempt to create the `appiumHome` directory', function () { + expect(mocks['@appium/support'].mkdirp).to.have.been.calledOnceWith( + '/some/path', + ); + }); + + it('should attempt to read the file at `filepath`', function () { + expect( + mocks['@appium/support'].fs.readFile, + ).to.have.been.calledOnceWith(io.filepath, 'utf8'); + }); + }); + + describe('when the `schemaRev` of the extension config file is less than 2', function () { + let updatedYaml; + + beforeEach(async function () { + mocks['@appium/support'].fs.readFile = sandbox + .stub() + .resolves(yamlFixtureRev1); + updatedYaml = { + ...YAML.parse(yamlFixtureRev1), + schemaRev: 2, + plugins: {}, + }; + await io.read('driver'); + }); + + it('should write the file using the updated schema', async function () { + await io.write(); + expect( + mocks['@appium/support'].fs.writeFile, + ).to.have.been.calledOnceWith( + io.filepath, + YAML.stringify(updatedYaml), + 'utf8', + ); + }); + }); + }); + + describe('when called with an unknown `extensionType`', function () { + it('should reject', async function () { + // @ts-ignore + return await expect(io.read('unknown')).to.be.rejectedWith( + TypeError, + /invalid extension type/i, + ); + }); + }); + + describe('when called twice with the same `extensionType`', function () { + it('should return the same object both times', async function () { + const firstInstance = await io.read('driver'); + const secondInstance = await io.read('driver'); + expect(firstInstance).to.equal(secondInstance); + }); + }); + }); + + describe('write()', function () { + let io; + let driverData; + + beforeEach(function () { + io = getExtConfigIOInstance('/some/path'); + }); + + describe('when called after `read()`', function () { + beforeEach(async function () { + driverData = await io.read('driver'); + }); + + describe('when called without modifying the data', function () { + it('should not write the file', async function () { + expect(await io.write()).to.be.false; + }); + }); + + describe('when called after adding a property', function () { + beforeEach(function () { + driverData.foo = { + name: 'foo', + version: '1.0.0', + path: '/foo/path', + }; + }); + + it('should write the file', async function () { + expect(await io.write()).to.be.true; + }); + }); + + describe('when called after deleting a property', function () { + beforeEach(function () { + driverData.foo = { + name: 'foo', + version: '1.0.0', + path: '/foo/path', + }; + io._dirty = false; + delete driverData.foo; + }); + + it('should write the file', async function () { + expect(await io.write()).to.be.true; + }); + }); + + describe('when the config file could not be written', function () { + beforeEach(function () { + mocks['@appium/support'].fs.writeFile = sandbox.stub().rejects(new Error()); + io._dirty = true; + }); + + it('should reject', async function () { + await expect(io.write()).to.be.rejectedWith(Error, /Appium could not parse or write/i); + }); + }); + + }); + + describe('when called before `read()`', function () { + it('should return `false`', async function () { + expect(await io.write()).to.be.false; + }); + + describe('when called with `force: true`', function () { + it('should reject', async function () { + await expect(io.write(true)).to.be.rejectedWith(ReferenceError, 'No data to write. Call `read()` first'); + }); + }); + }); + }); +}); diff --git a/packages/appium/test/extension-config-specs.js b/packages/appium/test/extension-config-specs.js new file mode 100644 index 000000000..39915f819 --- /dev/null +++ b/packages/appium/test/extension-config-specs.js @@ -0,0 +1,181 @@ +// @ts-check + +import path from 'path'; +import rewiremock from 'rewiremock/node'; +import sinon from 'sinon'; + +describe('ExtensionConfig', function () { + describe('DriverConfig', function () { + + /** + * @type {typeof import('../lib/driver-config').default} + */ + let DriverConfig; + let mocks; + + beforeEach(function () { + mocks = { + 'resolve-from': sinon.stub().callsFake((cwd, id) => path.join(cwd, id)) + }; + + DriverConfig = rewiremock.proxy( + () => require('../lib/driver-config'), + mocks, + ).default; + }); + + describe('extensionDesc', function () { + it('should return the description of the extension', function () { + const config = new DriverConfig('/tmp/'); + config.extensionDesc('foo', {version: '1.0', automationName: 'bar'}).should.equal(`foo@1.0 (automationName 'bar')`); + }); + }); + + describe('getConfigProblems()', function () { + /** + * @type {InstanceType} + */ + let driverConfig; + + beforeEach(function () { + driverConfig = new DriverConfig('/tmp/'); + }); + + describe('when provided no arguments', function () { + it('should throw', function () { + // @ts-ignore + (() => driverConfig.getConfigProblems()).should.throw(); + }); + }); + + describe('property `platformNames`', function () { + describe('when provided an object with no `platformNames` property', function () { + it('should return an array with an associated problem', function () { + driverConfig.getConfigProblems({}, 'foo').should.deep.include({ + err: 'Missing or incorrect supported platformNames list.', + val: undefined, + }); + }); + }); + + describe('when provided an object with an empty `platformNames` property', function () { + it('should return an array with an associated problem', function () { + driverConfig + .getConfigProblems({platformNames: []}, 'foo') + .should.deep.include({ + err: 'Empty platformNames list.', + val: [], + }); + }); + }); + + describe('when provided an object with a non-array `platformNames` property', function () { + it('should return an array with an associated problem', function () { + driverConfig + .getConfigProblems({platformNames: 'foo'}, 'foo') + .should.deep.include({ + err: 'Missing or incorrect supported platformNames list.', + val: 'foo', + }); + }); + }); + + describe('when provided a non-empty array containing a non-string item', function () { + it('should return an array with an associated problem', function () { + driverConfig + .getConfigProblems({platformNames: ['a', 1]}, 'foo') + .should.deep.include({ + err: 'Incorrectly formatted platformName.', + val: 1, + }); + }); + }); + }); + + describe('property `automationName`', function () { + describe('when provided an object with a missing `automationName` property', function () { + it('should return an array with an associated problem', function () { + driverConfig.getConfigProblems({}, 'foo').should.deep.include({ + err: 'Missing or incorrect automationName', + val: undefined, + }); + }); + }); + describe('when provided a conflicting automationName', function () { + it('should return an array with an associated problem', function () { + driverConfig.getConfigProblems({automationName: 'foo'}, 'foo'); + driverConfig + .getConfigProblems({automationName: 'foo'}, 'foo') + .should.deep.include({ + err: 'Multiple drivers claim support for the same automationName', + val: 'foo', + }); + }); + + }); + }); + }); + + describe('getSchemaProblems()', function () { + /** + * @type {InstanceType} + */ + let driverConfig; + + beforeEach(function () { + driverConfig = new DriverConfig('/tmp/'); + }); + describe('when provided an object with a defined non-string `schema` property', function () { + it('should return an array with an associated problem', function () { + driverConfig.getSchemaProblems({schema: []}, 'foo').should.deep.include({ + err: 'Incorrectly formatted schema field; must be a path to a schema file.', + val: [], + }); + }); + }); + + describe('when provided a string `schema` property', function () { + describe('when the property ends in an unsupported extension', function () { + it('should return an array with an associated problem', function () { + driverConfig + .getSchemaProblems({schema: 'selenium.java'}, 'foo') + .should.deep.include({ + err: 'Schema file has unsupported extension. Allowed: .json, .js, .cjs', + val: 'selenium.java', + }); + }); + }); + + describe('when the property contains a supported extension', function () { + describe('when the property as a path cannot be found', function () { + it('should return an array with an associated problem', function () { + const problems = driverConfig + .getSchemaProblems({ + installPath: '/usr/bin/derp', + pkgName: 'doop', + schema: 'herp.json', + }, 'foo'); + problems.should.deep.include({ + err: `Unable to register schema at path herp.json`, + val: 'herp.json', + }); + }); + }); + + describe('when the property as a path is found', function () { + it('should return an empty array', function () { + const problems = driverConfig.getSchemaProblems({ + pkgName: 'fixtures', // just corresponds to a directory name relative to `installPath` `(__dirname)` + installPath: __dirname, + schema: 'driver.schema.js', + platformNames: ['foo'], // to avoid problem + automationName: 'fake' // to avoid problem + }, 'foo'); + problems.should.be.empty; + }); + }); + }); + }); + }); + }); +}); diff --git a/packages/appium/test/fixtures/args-for-schema.js b/packages/appium/test/fixtures/args-for-schema.js new file mode 100644 index 000000000..c6d217018 --- /dev/null +++ b/packages/appium/test/fixtures/args-for-schema.js @@ -0,0 +1,254 @@ +export default [ + [ + ['--address', '-a'], + {dest: 'address', help: 'IP address to listen on', required: false}, + ], + [ + ['--allow-cors'], + { + action: 'store_true', + dest: 'allowCors', + help: 'Whether the Appium server should allow web browser connections from any host', + required: false, + }, + ], + [ + ['--allow-insecure'], + { + dest: 'allowInsecure', + help: 'Set which insecure features are allowed to run in this server\'s sessions. Features are defined on a driver level; see documentation for more details. Note that features defined via "deny-insecure" will be disabled, even if also listed here. If string, a path to a text file containing policy or a comma-delimited list.', + required: false, + }, + ], + [ + ['--base-path', '-pa'], + { + dest: 'basePath', + help: 'Base path to use as the prefix for all webdriver routes running on the server', + required: false, + }, + ], + [ + ['--callback-address', '-ca'], + { + dest: 'callbackAddress', + help: 'Callback IP address (default: same as "address")', + required: false, + }, + ], + [ + ['--callback-port', '-cp'], + { + dest: 'callbackPort', + help: 'Callback port (default: same as "port")', + required: false, + }, + ], + [ + ['--debug-log-spacing'], + { + action: 'store_true', + dest: 'debugLogSpacing', + help: 'Add exaggerated spacing in logs to help with visual inspection', + required: false, + }, + ], + [ + ['--default-capabilities', '-dc'], + { + dest: 'defaultCapabilities', + help: 'Set the default desired capabilities, which will be set on each session unless overridden by received capabilities. If a string, a path to a JSON file containing the capabilities, or raw JSON.', + required: false, + }, + ], + [ + ['--deny-insecure'], + { + dest: 'denyInsecure', + help: 'Set which insecure features are not allowed to run in this server\'s sessions. Features are defined on a driver level; see documentation for more details. Features listed here will not be enabled even if also listed in "allow-insecure", and even if "relaxed-security" is enabled. If string, a path to a text file containing policy or a comma-delimited list.', + required: false, + }, + ], + [ + ['--drivers'], + { + dest: 'drivers', + help: 'A list of drivers to activate. By default, all installed drivers will be activated. If a string, must be valid JSON', + required: false, + }, + ], + [ + ['--keep-alive-timeout', '-ka'], + { + dest: 'keepAliveTimeout', + help: 'Number of seconds the Appium server should apply as both the keep-alive timeout and the connection timeout for all requests. A value of 0 disables the timeout.', + required: false, + }, + ], + [ + ['--local-timezone'], + { + action: 'store_true', + dest: 'localTimezone', + help: 'Use local timezone for timestamps', + required: false, + }, + ], + [ + ['--log-file', '-g'], + { + dest: 'logFile', + help: 'Also send log output to this file', + required: false, + }, + ], + [ + ['--log-filters'], + { + dest: 'logFilters', + help: 'One or more log filtering rules', + required: false, + }, + ], + [ + ['--log-no-colors'], + { + action: 'store_true', + dest: 'logNoColors', + help: 'Do not use color in console output', + required: false, + }, + ], + [ + ['--log-timestamp'], + { + action: 'store_true', + dest: 'logTimestamp', + help: 'Show timestamps in console output', + required: false, + }, + ], + [ + ['--loglevel'], + { + choices: [ + 'info', + 'info:debug', + 'info:info', + 'info:warn', + 'info:error', + 'warn', + 'warn:debug', + 'warn:info', + 'warn:warn', + 'warn:error', + 'error', + 'error:debug', + 'error:info', + 'error:warn', + 'error:error', + 'debug', + 'debug:debug', + 'debug:info', + 'debug:warn', + 'debug:error', + ], + dest: 'loglevel', + help: 'Log level (console[:file])', + required: false, + }, + ], + [ + ['--long-stacktrace'], + { + action: 'store_true', + dest: 'longStacktrace', + help: 'Add long stack traces to log entries. Recommended for debugging only.', + required: false, + }, + ], + [ + ['--no-perms-check'], + { + action: 'store_true', + dest: 'noPermsCheck', + help: 'Do not check that needed files are readable and/or writable', + required: false, + }, + ], + [ + ['--nodeconfig'], + { + dest: 'nodeconfig', + help: 'Path to configuration JSON file to register Appium as a node with Selenium Grid 3; otherwise the configuration itself', + required: false, + }, + ], + [ + ['--plugins'], + { + dest: 'plugins', + help: 'A list of plugins to activate. To activate all plugins, use the single string "all". If a string, can otherwise be valid JSON.', + required: false, + }, + ], + [ + ['--port', '-p'], + { + dest: 'port', + help: 'Port to listen on', + required: false, + }, + ], + [ + ['--relaxed-security'], + { + action: 'store_true', + dest: 'relaxedSecurity', + help: 'Disable additional security checks, so it is possible to use some advanced features, provided by drivers supporting this option. Only enable it if all the clients are in the trusted network and it\'s not the case if a client could potentially break out of the session sandbox. Specific features can be overridden by using "deny-insecure"', + required: false, + }, + ], + [ + ['--session-override'], + { + action: 'store_true', + dest: 'sessionOverride', + help: 'Enables session override (clobbering)', + required: false, + }, + ], + [ + ['--strict-caps'], + { + action: 'store_true', + dest: 'strictCaps', + help: 'Cause sessions to fail if desired caps are sent in that Appium does not recognize as valid for the selected device', + required: false, + }, + ], + [ + ['--tmp'], + { + dest: 'tmp', + help: 'Absolute path to directory Appium can use to manage temp files. Defaults to C:\\Windows\\Temp on Windows and /tmp otherwise.', + required: false, + }, + ], + [ + ['--trace-dir'], + { + dest: 'traceDir', + help: 'Absolute path to directory Appium can use to save iOS instrument traces; defaults to /appium-instruments', + required: false, + }, + ], + [ + ['--webhook', '-G'], + { + dest: 'webhook', + help: 'Also send log output to this http listener', + required: false, + }, + ], +]; diff --git a/packages/appium/test/fixtures/config/allow-insecure.txt b/packages/appium/test/fixtures/config/allow-insecure.txt new file mode 100644 index 000000000..86e041dad --- /dev/null +++ b/packages/appium/test/fixtures/config/allow-insecure.txt @@ -0,0 +1,3 @@ +foo +bar +baz diff --git a/packages/appium/test/fixtures/config/appium.config.bad.json b/packages/appium/test/fixtures/config/appium.config.bad.json new file mode 100644 index 000000000..41ae517d9 --- /dev/null +++ b/packages/appium/test/fixtures/config/appium.config.bad.json @@ -0,0 +1,32 @@ +{ + "appium-home": "foo", + "server": { + "address": "0.0.0.0", + "port": "31337", + "allow-cors": 1, + "allow-insecure": {}, + "base-path": "/", + "callback-address": "0.0.0.0", + "callback-port": 43243234, + "log-level": "smoosh", + "log-timestamp": false, + "debug-log-spacing": false, + "default-capabilities": {}, + "deny-insecure": [], + "drivers": [], + "keep-alive-timeout": 0, + "local-timezone": false, + "log": "/tmp/appium.log", + "log-no-colors": 1, + "long-stacktrace": false, + "no-perms-check": false, + "nodeconfig": {}, + "plugins": "all", + "relaxed-security": false, + "session-override": false, + "strict-caps": false, + "tmp": "/tmp", + "trace-dir": "/tmp/appium-instruments", + "webhook": "http://0.0.0.0/hook" + } +} diff --git a/packages/appium/test/fixtures/config/appium.config.ext-unknown-props.json b/packages/appium/test/fixtures/config/appium.config.ext-unknown-props.json new file mode 100644 index 000000000..434b4dc7e --- /dev/null +++ b/packages/appium/test/fixtures/config/appium.config.ext-unknown-props.json @@ -0,0 +1,8 @@ +{ + "driver": { + "fake": { + "answer": 24, + "bubb": "rubb" + } + } +} diff --git a/packages/appium/test/fixtures/config/appium.config.good.json b/packages/appium/test/fixtures/config/appium.config.good.json new file mode 100644 index 000000000..44f2ebe3d --- /dev/null +++ b/packages/appium/test/fixtures/config/appium.config.good.json @@ -0,0 +1,33 @@ +{ + "server": { + "address": "0.0.0.0", + "port": 31337, + "allow-cors": false, + "allow-insecure": [], + "base-path": "/", + "callback-address": "0.0.0.0", + "callback-port": 31337, + "log-level": "info", + "log-timestamp": false, + "debug-log-spacing": false, + "default-capabilities": {}, + "deny-insecure": [], + "drivers": [], + "keep-alive-timeout": 600, + "local-timezone": false, + "log": "/tmp/appium.log", + "log-no-colors": false, + "long-stacktrace": false, + "no-perms-check": false, + "nodeconfig": { + "foo": "bar" + }, + "plugins": "all", + "relaxed-security": false, + "session-override": false, + "strict-caps": false, + "tmp": "/tmp", + "trace-dir": "/tmp/appium-instruments", + "webhook": "http://0.0.0.0/hook" + } +} diff --git a/packages/appium/test/fixtures/config/appium.config.good.yaml b/packages/appium/test/fixtures/config/appium.config.good.yaml new file mode 100644 index 000000000..50f37340c --- /dev/null +++ b/packages/appium/test/fixtures/config/appium.config.good.yaml @@ -0,0 +1,29 @@ +server: + address: '0.0.0.0' + port: 31337 + allow-cors: false + allow-insecure: [] + base-path: / + callback-address: '0.0.0.0' + callback-port: 31337 + log-level: info + log-timestamp: false + debug-log-spacing: false + default-capabilities: {} + deny-insecure: [] + drivers: [] + keep-alive-timeout: 600 + local-timezone: false + log: /tmp/appium.log + log-no-colors: false + long-stacktrace: false + no-perms-check: false + nodeconfig: + foo: bar + plugins: all + relaxed-security: false + session-override: false + strict-caps: false + tmp: /tmp + trace-dir: /tmp/appium-instruments + webhook: 'http://0.0.0.0/hook' diff --git a/packages/appium/test/fixtures/config/appium.config.invalid.json b/packages/appium/test/fixtures/config/appium.config.invalid.json new file mode 100644 index 000000000..276ca5915 --- /dev/null +++ b/packages/appium/test/fixtures/config/appium.config.invalid.json @@ -0,0 +1,31 @@ +{ + "server": { + "address": "0.0.0.0", + "port": 31337, + "allow-cors": false, + "allow-insecure": [], + "base-path": "/", + "callback-address": "0.0.0.0", + "callback-port": 31337, + "log-level": "info", + "log-timestamp": false, + "debug-log-spacing": false, + "default-capabilities": {}, + "deny-insecure": [], + "drivers": [], + "keep-alive-timeout": 600, + "local-timezone": false, + "log": "/tmp/appium.log", + "log-no-colors": false, + "long-stacktrace": false, + "no-perms-check": false, + "nodeconfig": {}, + "plugins": "all", + "relaxed-security": false, + "session-override": false, + "strict-caps": false, + "tmp": '/tmp', + "trace-dir": "/tmp/appium-instruments", + "webhook": "http://0.0.0.0/hook" + } +} diff --git a/packages/appium/test/fixtures/config/appium.config.nodeconfig-path.json b/packages/appium/test/fixtures/config/appium.config.nodeconfig-path.json new file mode 100644 index 000000000..d3f58a67c --- /dev/null +++ b/packages/appium/test/fixtures/config/appium.config.nodeconfig-path.json @@ -0,0 +1,5 @@ +{ + "server": { + "nodeconfig": "./packages/appium/test/fixtures/config/nodeconfig.json" + } +} diff --git a/packages/appium/test/fixtures/config/appium.config.security-array.json b/packages/appium/test/fixtures/config/appium.config.security-array.json new file mode 100644 index 000000000..18f92d1cd --- /dev/null +++ b/packages/appium/test/fixtures/config/appium.config.security-array.json @@ -0,0 +1,5 @@ +{ + "server": { + "allow-insecure": ["foo", "bar", "baz"] + } +} diff --git a/packages/appium/test/fixtures/config/appium.config.security-delimited.json b/packages/appium/test/fixtures/config/appium.config.security-delimited.json new file mode 100644 index 000000000..d19b0cdca --- /dev/null +++ b/packages/appium/test/fixtures/config/appium.config.security-delimited.json @@ -0,0 +1,5 @@ +{ + "server": { + "allow-insecure": "foo,bar,baz" + } +} diff --git a/packages/appium/test/fixtures/config/appium.config.security-path.json b/packages/appium/test/fixtures/config/appium.config.security-path.json new file mode 100644 index 000000000..61eaceb94 --- /dev/null +++ b/packages/appium/test/fixtures/config/appium.config.security-path.json @@ -0,0 +1,5 @@ +{ + "server": { + "allow-insecure": "./packages/appium/test/fixtures/config/allow-insecure.txt" + } +} diff --git a/packages/appium/test/fixtures/config/driver-fake.config.json b/packages/appium/test/fixtures/config/driver-fake.config.json new file mode 100644 index 000000000..c0682f272 --- /dev/null +++ b/packages/appium/test/fixtures/config/driver-fake.config.json @@ -0,0 +1,8 @@ +{ + "driver": { + "fake": { + "sillyWebServerPort": 1234, + "sillyWebServerHost": "hey" + } + } +} diff --git a/packages/appium/test/fixtures/config/nodeconfig.json b/packages/appium/test/fixtures/config/nodeconfig.json new file mode 100644 index 000000000..bb2ecada7 --- /dev/null +++ b/packages/appium/test/fixtures/config/nodeconfig.json @@ -0,0 +1,3 @@ +{ + "random": "stuff" +} diff --git a/packages/appium/test/fixtures/config/plugin-fake.config.json b/packages/appium/test/fixtures/config/plugin-fake.config.json new file mode 100644 index 000000000..e69de29bb diff --git a/packages/appium/test/fixtures/default-args.js b/packages/appium/test/fixtures/default-args.js new file mode 100644 index 000000000..34a680276 --- /dev/null +++ b/packages/appium/test/fixtures/default-args.js @@ -0,0 +1,23 @@ +export default { + address: '0.0.0.0', + allowCors: false, + allowInsecure: [], + basePath: '', + callbackPort: 4723, + debugLogSpacing: false, + denyInsecure: [], + drivers: '', + keepAliveTimeout: 600, + localTimezone: false, + loglevel: 'debug', + logNoColors: false, + logTimestamp: false, + longStacktrace: false, + noPermsCheck: false, + nodeconfig: '', + plugins: '', + port: 4723, + relaxedSecurity: false, + sessionOverride: false, + strictCaps: false, +}; diff --git a/packages/appium/test/fixtures/driver.schema.js b/packages/appium/test/fixtures/driver.schema.js new file mode 100644 index 000000000..2b11f58f9 --- /dev/null +++ b/packages/appium/test/fixtures/driver.schema.js @@ -0,0 +1,14 @@ +module.exports = { + type: 'object', + required: ['answer'], + properties: { + answer: { + type: 'number', + minimum: 0, + maximum: 100, + default: 50, + description: + 'The answer to the Ultimate Question of Life, The Universe, and Everything', + }, + }, +}; diff --git a/packages/appium/test/fixtures/driverArgs.json b/packages/appium/test/fixtures/driverArgs.json deleted file mode 100644 index 850c9c35f..000000000 --- a/packages/appium/test/fixtures/driverArgs.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "fake":{ - "sillyWebServerPort":1234, - "host":"hey" - } - } \ No newline at end of file diff --git a/packages/appium/test/fixtures/extensions-rev-1.yaml b/packages/appium/test/fixtures/extensions-rev-1.yaml new file mode 100644 index 000000000..a92425f9d --- /dev/null +++ b/packages/appium/test/fixtures/extensions-rev-1.yaml @@ -0,0 +1,16 @@ +drivers: + fake: + automationName: Fake + platformNames: + - Fake + mainClass: FakeDriver + schema: ./build/lib/fake-driver-schema.js + scripts: + fake-error: ./build/lib/scripts/fake-error.js + fake-success: ./build/lib/scripts/fake-success.js + pkgName: '@appium/fake-driver' + version: 3.0.5 + installPath: '@appium/fake-driver' + installType: local + installSpec: /Users/alice/projects/appium/packages/fake-driver +schemaRev: 1 diff --git a/packages/appium/test/fixtures/extensions.yaml b/packages/appium/test/fixtures/extensions.yaml new file mode 100644 index 000000000..478ab5203 --- /dev/null +++ b/packages/appium/test/fixtures/extensions.yaml @@ -0,0 +1,27 @@ +drivers: + fake: + automationName: Fake + platformNames: + - Fake + mainClass: FakeDriver + schema: ./build/lib/fake-driver-schema.js + scripts: + fake-error: ./build/lib/scripts/fake-error.js + fake-success: ./build/lib/scripts/fake-success.js + pkgName: '@appium/fake-driver' + version: 3.0.5 + installPath: '@appium/fake-driver' + installType: local + installSpec: /Users/alice/projects/appium/packages/fake-driver +plugins: + fake: + mainClass: FakePlugin + scripts: + fake-error: ./build/lib/scripts/fake-error.js + fake-success: ./build/lib/scripts/fake-success.js + pkgName: '@appium/fake-plugin' + version: 1.2.2 + installPath: '@appium/fake-plugin' + installType: local + installSpec: /Users/alice/projects/appium/node_modules/@appium/fake-plugin +schemaRev: 2 diff --git a/packages/appium/test/fixtures/flattened-schema.js b/packages/appium/test/fixtures/flattened-schema.js new file mode 100644 index 000000000..8700a7eac --- /dev/null +++ b/packages/appium/test/fixtures/flattened-schema.js @@ -0,0 +1,261 @@ +export default { + address: { + $comment: 'I think hostname covers both DNS and IPv4...could be wrong', + $id: '#/properties/server/properties/address', + appiumCliAliases: ['a'], + default: '0.0.0.0', + description: 'IP address to listen on', + format: 'hostname', + title: 'address config', + type: 'string', + }, + allowCors: { + $id: '#/properties/server/properties/allow-cors', + default: false, + description: + 'Whether the Appium server should allow web browser connections from any host', + title: 'allow-cors config', + type: 'boolean', + }, + allowInsecure: { + $id: '#/properties/server/properties/allow-insecure', + default: [], + description: + 'Set which insecure features are allowed to run in this server\'s sessions. Features are defined on a driver level; see documentation for more details. Note that features defined via "deny-insecure" will be disabled, even if also listed here. If string, a path to a text file containing policy or a comma-delimited list.', + items: {type: 'string'}, + title: 'allow-insecure config', + type: ['array', 'string'], + uniqueItems: true, + }, + basePath: { + $id: '#/properties/server/properties/base-path', + appiumCliAliases: ['pa'], + default: '', + description: + 'Base path to use as the prefix for all webdriver routes running on the server', + title: 'base-path config', + type: 'string', + }, + callbackAddress: { + $id: '#/properties/server/properties/callback-address', + appiumCliAliases: ['ca'], + description: 'Callback IP address (default: same as "address")', + title: 'callback-address config', + type: 'string', + }, + callbackPort: { + $id: '#/properties/server/properties/callback-port', + appiumCliAliases: ['cp'], + default: 4723, + description: 'Callback port (default: same as "port")', + maximum: 65535, + minimum: 1, + title: 'callback-port config', + type: 'integer', + }, + debugLogSpacing: { + $id: '#/properties/server/properties/debug-log-spacing', + default: false, + description: + 'Add exaggerated spacing in logs to help with visual inspection', + title: 'debug-log-spacing config', + type: 'boolean', + }, + defaultCapabilities: { + $comment: 'TODO', + $id: '#/properties/server/properties/default-capabilities', + appiumCliAliases: ['dc'], + description: + 'Set the default desired capabilities, which will be set on each session unless overridden by received capabilities. If a string, a path to a JSON file containing the capabilities, or raw JSON.', + title: 'default-capabilities config', + type: ['object', 'string'], + }, + denyInsecure: { + $comment: 'Allowed values are defined by drivers', + $id: '#/properties/server/properties/deny-insecure', + default: [], + description: + 'Set which insecure features are not allowed to run in this server\'s sessions. Features are defined on a driver level; see documentation for more details. Features listed here will not be enabled even if also listed in "allow-insecure", and even if "relaxed-security" is enabled. If string, a path to a text file containing policy or a comma-delimited list.', + items: {type: 'string'}, + title: 'deny-insecure config', + type: ['array', 'string'], + uniqueItems: true, + }, + drivers: { + $id: '#/properties/server/properties/drivers', + default: '', + description: + 'A list of drivers to activate. By default, all installed drivers will be activated.', + items: {type: 'string'}, + title: 'drivers config', + type: ['string', 'array'], + }, + keepAliveTimeout: { + $id: '#/properties/server/properties/keep-alive-timeout', + appiumCliAliases: ['ka'], + default: 600, + description: + 'Number of seconds the Appium server should apply as both the keep-alive timeout and the connection timeout for all requests. A value of 0 disables the timeout.', + minimum: 0, + title: 'keep-alive-timeout config', + type: 'integer', + }, + localTimezone: { + $id: '#/properties/server/properties/local-timezone', + default: false, + description: 'Use local timezone for timestamps', + title: 'local-timezone config', + type: 'boolean', + }, + logFile: { + $id: '#/properties/server/properties/log', + appiumCliAliases: ['g'], + appiumCliDest: 'logFile', + description: 'Also send log output to this file', + title: 'log config', + type: 'string', + }, + logFilters: { + $comment: 'TODO', + $id: '#/properties/log-filters', + description: 'One or more log filtering rules', + items: {type: 'string'}, + title: 'log-filters config', + type: 'array', + }, + logNoColors: { + $id: '#/properties/server/properties/log-no-colors', + default: false, + description: 'Do not use color in console output', + title: 'log-no-colors config', + type: 'boolean', + }, + logTimestamp: { + $id: '#/properties/server/properties/log-timestamp', + default: false, + description: 'Show timestamps in console output', + title: 'log-timestamp config', + type: 'boolean', + }, + loglevel: { + $id: '#/properties/server/properties/log-level', + appiumCliDest: 'loglevel', + default: 'debug', + description: 'Log level (console[:file])', + enum: [ + 'info', + 'info:debug', + 'info:info', + 'info:warn', + 'info:error', + 'warn', + 'warn:debug', + 'warn:info', + 'warn:warn', + 'warn:error', + 'error', + 'error:debug', + 'error:info', + 'error:warn', + 'error:error', + 'debug', + 'debug:debug', + 'debug:info', + 'debug:warn', + 'debug:error', + ], + title: 'log-level config', + type: 'string', + }, + longStacktrace: { + $id: '#/properties/server/properties/long-stacktrace', + default: false, + description: + 'Add long stack traces to log entries. Recommended for debugging only.', + title: 'long-stacktrace config', + type: 'boolean', + }, + noPermsCheck: { + $id: '#/properties/server/properties/no-perms-check', + default: false, + description: 'Do not check that needed files are readable and/or writable', + title: 'no-perms-check config', + type: 'boolean', + }, + nodeconfig: { + $comment: + 'Selenium Grid 3 is unmaintained and Selenium Grid 4 no longer supports this file.', + $id: '#/properties/server/properties/nodeconfig', + default: '', + description: + 'Path to configuration JSON file to register Appium as a node with Selenium Grid 3; otherwise the configuration itself', + title: 'nodeconfig config', + type: ['object', 'string'], + }, + plugins: { + $id: '#/properties/server/properties/plugins', + default: '', + description: + 'A list of plugins to activate. To activate all plugins, use the single string "all"', + items: {type: 'string'}, + title: 'plugins config', + type: ['string', 'array'], + }, + port: { + $id: '#/properties/server/properties/port', + appiumCliAliases: ['p'], + default: 4723, + description: 'Port to listen on', + maximum: 65535, + minimum: 1, + title: 'port config', + type: 'integer', + }, + relaxedSecurity: { + $id: '#/properties/server/properties/relaxed-security', + default: false, + description: + 'Disable additional security checks, so it is possible to use some advanced features, provided by drivers supporting this option. Only enable it if all the clients are in the trusted network and it\'s not the case if a client could potentially break out of the session sandbox. Specific features can be overridden by using "deny-insecure"', + title: 'relaxed-security config', + type: 'boolean', + }, + sessionOverride: { + $id: '#/properties/server/properties/session-override', + default: false, + description: 'Enables session override (clobbering)', + title: 'session-override config', + type: 'boolean', + }, + strictCaps: { + $id: '#/properties/server/properties/strict-caps', + default: false, + description: + 'Cause sessions to fail if desired caps are sent in that Appium does not recognize as valid for the selected device', + title: 'strict-caps config', + type: 'boolean', + }, + tmp: { + $id: '#/properties/server/properties/tmp', + description: + 'Absolute path to directory Appium can use to manage temp files. Defaults to C:\\Windows\\Temp on Windows and /tmp otherwise.', + title: 'tmp config', + type: 'string', + }, + traceDir: { + $id: '#/properties/server/properties/trace-dir', + description: + 'Absolute path to directory Appium can use to save iOS instrument traces; defaults to /appium-instruments', + title: 'trace-dir config', + type: 'string', + }, + webhook: { + $comment: + 'This should probably use a uri-template format to restrict the protocol to http/https', + $id: '#/properties/server/properties/webhook', + appiumCliAliases: ['G'], + description: 'Also send log output to this http listener', + format: 'uri', + title: 'webhook config', + type: 'string', + }, +}; diff --git a/packages/appium/test/fixtures/plugin.schema.js b/packages/appium/test/fixtures/plugin.schema.js new file mode 100644 index 000000000..2b11f58f9 --- /dev/null +++ b/packages/appium/test/fixtures/plugin.schema.js @@ -0,0 +1,14 @@ +module.exports = { + type: 'object', + required: ['answer'], + properties: { + answer: { + type: 'number', + minimum: 0, + maximum: 100, + default: 50, + description: + 'The answer to the Ultimate Question of Life, The Universe, and Everything', + }, + }, +}; diff --git a/packages/appium/test/fixtures/schema-with-extensions.js b/packages/appium/test/fixtures/schema-with-extensions.js new file mode 100644 index 000000000..6d8dc192d --- /dev/null +++ b/packages/appium/test/fixtures/schema-with-extensions.js @@ -0,0 +1,12 @@ +// this fixture combines the base config schema and the fake-driver schema, as would happen in a real use case. +import _ from 'lodash'; +import appiumConfigSchema from '../../lib/appium-config-schema'; + +// this must be a `require` because babel will not compile `node_modules` +// TOOD: consider just copying this into fixtures so we can make it ESM +const {default: fakeDriverSchema} = require('@appium/fake-driver/build/lib/fake-driver-schema'); + +const schema = _.cloneDeep(appiumConfigSchema); +_.set(schema, 'properties.driver.properties.fake', fakeDriverSchema); + +export default schema; diff --git a/packages/appium/test/grid-register-specs.js b/packages/appium/test/grid-register-specs.js new file mode 100644 index 000000000..5989a4d70 --- /dev/null +++ b/packages/appium/test/grid-register-specs.js @@ -0,0 +1,81 @@ +import sinon from 'sinon'; +import {rewiremock} from './helpers'; + +describe('grid-register', function () { + let sandbox; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('registerNode()', function () { + let registerNode; + let mocks; + + beforeEach(function () { + mocks = { + '@appium/support': { + fs: { + readFile: sandbox.stub().resolves('{}'), + }, + }, + axios: sandbox.stub().resolves({data: '', status: 200}), + '../lib/logger': { + error: sandbox.stub(), + errorAndThrow: sandbox.stub().throws(), + debug: sandbox.stub(), + warn: sandbox.stub(), + }, + }; + + registerNode = rewiremock.proxy( + () => require('../lib/grid-register'), + mocks, + ).default; + }); + + describe('when provided a path to a config file', function () { + it('should read the config file', async function () { + await registerNode('/path/to/config-file.json'); + mocks['@appium/support'].fs.readFile.should.have.been.calledOnceWith( + '/path/to/config-file.json', + 'utf-8', + ); + }); + + it('should parse the config file as JSON', async function () { + sandbox.spy(JSON, 'parse'); + await registerNode('/path/to/config-file.json'); + JSON.parse.should.have.been.calledOnceWith( + await mocks['@appium/support'].fs.readFile.firstCall.returnValue, + ); + }); + + describe('when the config file is invalid', function () { + beforeEach(function () { + mocks['@appium/support'].fs.readFile.resolves(''); + }); + it('should reject', async function () { + await registerNode('/path/to/config-file.json').should.be.rejected; + }); + }); + }); + + describe('when provided a config object', function () { + it('should not attempt to read the object as a config file', async function () { + await registerNode({my: 'config'}); + mocks['@appium/support'].fs.readFile.should.not.have.been.called; + }); + + it('should not attempt to parse any JSON', async function () { + sandbox.spy(JSON, 'parse'); + await registerNode({my: 'config'}); + JSON.parse.should.not.have.been.called; + }); + }); + }); +}); diff --git a/packages/appium/test/helpers.js b/packages/appium/test/helpers.js index b6cfbbe74..1e0541030 100644 --- a/packages/appium/test/helpers.js +++ b/packages/appium/test/helpers.js @@ -1,3 +1,4 @@ +import rewiremock, {addPlugin, overrideEntryPoint, plugins} from 'rewiremock'; import path from 'path'; import {insertAppiumPrefixes} from '../lib/utils'; import getPort from 'get-port'; @@ -29,4 +30,18 @@ async function getTestPort () { return await (TEST_PORT || getPort()); } -export { TEST_FAKE_APP, TEST_HOST, BASE_CAPS, W3C_PREFIXED_CAPS, W3C_CAPS, PROJECT_ROOT, getTestPort }; +/** + * Resolve a file relative to the `fixtures` dir + * @param {string} filename - Filename to resolve + * @param {...string} [pathParts] - Additional paths to `join()` + * @returns {string} + */ +function resolveFixture (filename, ...pathParts) { + return path.join(__dirname, 'fixtures', filename, ...pathParts); +} + +overrideEntryPoint(module); +addPlugin(plugins.nodejs); +addPlugin(plugins.childOnly); + +export { TEST_FAKE_APP, TEST_HOST, BASE_CAPS, W3C_PREFIXED_CAPS, W3C_CAPS, PROJECT_ROOT, getTestPort, rewiremock, resolveFixture }; diff --git a/packages/appium/test/parser-specs.js b/packages/appium/test/parser-specs.js index 7b9905a79..040cb457d 100644 --- a/packages/appium/test/parser-specs.js +++ b/packages/appium/test/parser-specs.js @@ -1,244 +1,293 @@ // transpile:mocha import { getParser } from '../lib/cli/parser'; -import { INSTALL_TYPES, DEFAULT_APPIUM_HOME } from '../lib/extension-config'; -import path from 'path'; +import { INSTALL_TYPES } from '../lib/extension-config'; +import * as schema from '../lib/schema'; +import { readConfigFile } from '../lib/config-file'; +import { resolveFixture } from './helpers'; -const FIXTURE_DIR = path.join(__dirname, 'fixtures'); -const ALLOW_FIXTURE = path.join(FIXTURE_DIR, 'allow-feat.txt'); -const DENY_FIXTURE = path.join(FIXTURE_DIR, 'deny-feat.txt'); -const FAKE_DRIVER_ARGS_FIXTURE = path.join(FIXTURE_DIR, 'driverArgs.json'); -const CAPS_FIXTURE = path.join(FIXTURE_DIR, 'caps.json'); +// these paths should not make assumptions about the current working directory +const ALLOW_FIXTURE = resolveFixture('allow-feat.txt'); +const DENY_FIXTURE = resolveFixture('deny-feat.txt'); +const CAPS_FIXTURE = resolveFixture('caps.json'); -describe('Main Parser', function () { - let p = getParser(true); - it('should accept only server and driver subcommands', function () { - p.parse_args([]); - p.parse_args(['server']); - p.parse_args(['driver', 'list']); - (() => p.parse_args(['foo'])).should.throw(); - (() => p.parse_args(['foo --bar'])).should.throw(); - }); -}); +describe('parser', function () { + let p; -describe('Server Parser', function () { - let p = getParser(true); - it('should return an arg parser', function () { - should.exist(p.parse_args); - p.parse_args([]).should.have.property('port'); - }); - it('should default to the server subcommand', function () { - p.parse_args([]).subcommand.should.eql('server'); - p.parse_args([]).should.eql(p.parse_args(['server'])); - }); - it('should keep the raw server flags array', function () { - should.exist(p.rawArgs); - }); - it('should have help for every arg', function () { - for (let arg of p.rawArgs) { - arg[1].should.have.property('help'); - } - }); - it('should throw an error with unknown argument', function () { - (() => {p.parse_args(['--apple']);}).should.throw(); - }); - it('should parse default capabilities correctly from a string', function () { - let defaultCapabilities = {a: 'b'}; - let args = p.parse_args(['--default-capabilities', JSON.stringify(defaultCapabilities)]); - args.defaultCapabilities.should.eql(defaultCapabilities); - }); - it('should parse default capabilities correctly from a file', function () { - let defaultCapabilities = {a: 'b'}; - let args = p.parse_args(['--default-capabilities', CAPS_FIXTURE]); - args.defaultCapabilities.should.eql(defaultCapabilities); - }); - it('should throw an error with invalid arg to default capabilities', function () { - (() => {p.parse_args(['-dc', '42']);}).should.throw(); - (() => {p.parse_args(['-dc', 'false']);}).should.throw(); - (() => {p.parse_args(['-dc', 'null']);}).should.throw(); - (() => {p.parse_args(['-dc', 'does/not/exist.json']);}).should.throw(); - }); - it('should parse --allow-insecure correctly', function () { - p.parse_args([]).allowInsecure.should.eql([]); - p.parse_args(['--allow-insecure', '']).allowInsecure.should.eql([]); - p.parse_args(['--allow-insecure', 'foo']).allowInsecure.should.eql(['foo']); - p.parse_args(['--allow-insecure', 'foo,bar']).allowInsecure.should.eql(['foo', 'bar']); - p.parse_args(['--allow-insecure', 'foo ,bar']).allowInsecure.should.eql(['foo', 'bar']); - }); - it('should parse --deny-insecure correctly', function () { - p.parse_args([]).denyInsecure.should.eql([]); - p.parse_args(['--deny-insecure', '']).denyInsecure.should.eql([]); - p.parse_args(['--deny-insecure', 'foo']).denyInsecure.should.eql(['foo']); - p.parse_args(['--deny-insecure', 'foo,bar']).denyInsecure.should.eql(['foo', 'bar']); - p.parse_args(['--deny-insecure', 'foo ,bar']).denyInsecure.should.eql(['foo', 'bar']); - }); - it('should parse --allow and --deny insecure from files', function () { - const parsed = p.parse_args([ - '--allow-insecure', ALLOW_FIXTURE, '--deny-insecure', DENY_FIXTURE - ]); - parsed.allowInsecure.should.eql(['feature1', 'feature2', 'feature3']); - parsed.denyInsecure.should.eql(['nofeature1', 'nofeature2', 'nofeature3']); - }); - it('should parse default driver args correctly from a string', function () { - let fakeDriverArgs = {fake: {sillyWebServerPort: 1234, host: 'hey'}}; - let args = p.parse_args(['--driver-args', JSON.stringify(fakeDriverArgs)]); - args.driverArgs.should.eql(fakeDriverArgs); - }); - it('should parse default driver args correctly from a file', function () { - let fakeDriverArgs = {fake: {sillyWebServerPort: 1234, host: 'hey'}}; - let args = p.parse_args(['--driver-args', FAKE_DRIVER_ARGS_FIXTURE]); - args.driverArgs.should.eql(fakeDriverArgs); - }); - it('should parse default plugin args correctly from a string', function () { - let fakePluginArgs = {fake: {sillyWebServerPort: 1234, host: 'hey'}}; - let args = p.parse_args(['--plugin-args', JSON.stringify(fakePluginArgs)]); - args.pluginArgs.should.eql(fakePluginArgs); - }); -}); + describe('Main Parser', function () { + beforeEach(async function () { + p = await getParser(true); + }); -describe('Driver Parser', function () { - let p = getParser(true); - it('should not allow random sub-subcommands', function () { - (() => p.parse_args(['driver', 'foo'])).should.throw(); - }); - describe('list', function () { - it('should allow an empty argument list', function () { - const args = p.parse_args(['driver', 'list']); - args.subcommand.should.eql('driver'); - args.driverCommand.should.eql('list'); - args.showInstalled.should.eql(false); - args.showUpdates.should.eql(false); - args.json.should.eql(false); - args.appiumHome.should.eql(DEFAULT_APPIUM_HOME); - }); - it('should allow json format', function () { - const args = p.parse_args(['driver', 'list', '--json']); - args.json.should.eql(true); - }); - it('should allow custom appium home', function () { - const args = p.parse_args(['driver', 'list', '--home', '/foo/bar']); - args.appiumHome.should.eql('/foo/bar'); - }); - it('should allow --installed', function () { - const args = p.parse_args(['driver', 'list', '--installed']); - args.showInstalled.should.eql(true); - }); - it('should allow --updates', function () { - const args = p.parse_args(['driver', 'list', '--updates']); - args.showUpdates.should.eql(true); + it('should accept only server and driver subcommands', function () { + p.parse_args([]); + p.parse_args(['server']); + p.parse_args(['driver', 'list']); + (() => p.parse_args(['foo'])).should.throw(); + (() => p.parse_args(['foo --bar'])).should.throw(); }); }); - describe('install', function () { - it('should not allow an empty argument list', function () { - (() => p.parse_args(['driver', 'install'])).should.throw(); + + describe('Server Parser', function () { + describe('Appium arguments', function () { + beforeEach(async function () { + p = await getParser(true); + }); + + it('should return an arg parser', function () { + should.exist(p.parse_args); + p.parse_args([]).should.have.property('port'); + }); + it('should default to the server subcommand', function () { + p.parse_args([]).subcommand.should.eql('server'); + p.parse_args([]).should.eql(p.parse_args(['server'])); + }); + it('should keep the raw server flags array', function () { + should.exist(p.rawArgs); + }); + it('should have help for every arg', function () { + for (let arg of p.rawArgs) { + arg[1].should.have.property('help'); + } + }); + + describe('invalid arguments', function () { + it('should throw an error with unknown argument', function () { + (() => {p.parse_args(['--apple']);}).should.throw(/unrecognized arguments: --apple/i); + }); + + it('should throw an error for an invalid value ("hostname")', function () { + (() => {p.parse_args(['--address', '-42']);}).should.throw(/Value must be a valid hostname; received -42/i); + }); + + it('should throw an error for an invalid value ("uri")', function () { + (() => {p.parse_args(['--webhook', '-42']);}).should.throw(/Value must be a valid uri; received -42/i); + }); + + it('should throw an error for an invalid value (using "enum")', function () { + (() => {p.parse_args(['--loglevel', '-42']);}).should.throw(/invalid choice: '-42' \(choose from 'info'/i); + }); + }); + + it('should parse default capabilities correctly from a string', function () { + let defaultCapabilities = {a: 'b'}; + let args = p.parse_args(['--default-capabilities', JSON.stringify(defaultCapabilities)]); + args.defaultCapabilities.should.eql(defaultCapabilities); + }); + + it('should parse default capabilities correctly from a file', function () { + let defaultCapabilities = {a: 'b'}; + let args = p.parse_args(['--default-capabilities', CAPS_FIXTURE]); + args.defaultCapabilities.should.eql(defaultCapabilities); + }); + + it('should throw an error with invalid arg to default capabilities', function () { + (() => {p.parse_args(['-dc', '42']);}).should.throw(); + (() => {p.parse_args(['-dc', 'false']);}).should.throw(); + (() => {p.parse_args(['-dc', 'null']);}).should.throw(); + (() => {p.parse_args(['-dc', 'does/not/exist.json']);}).should.throw(); + }); + + it('should parse --allow-insecure correctly', function () { + p.parse_args([]).should.have.property('allowInsecure', undefined); + p.parse_args(['--allow-insecure', '']).allowInsecure.should.eql([]); + p.parse_args(['--allow-insecure', 'foo']).allowInsecure.should.eql(['foo']); + p.parse_args(['--allow-insecure', 'foo,bar']).allowInsecure.should.eql(['foo', 'bar']); + p.parse_args(['--allow-insecure', 'foo ,bar']).allowInsecure.should.eql(['foo', 'bar']); + }); + + it('should parse --deny-insecure correctly', function () { + p.parse_args([]).should.have.property('denyInsecure', undefined); + p.parse_args(['--deny-insecure', '']).denyInsecure.should.eql([]); + p.parse_args(['--deny-insecure', 'foo']).denyInsecure.should.eql(['foo']); + p.parse_args(['--deny-insecure', 'foo,bar']).denyInsecure.should.eql(['foo', 'bar']); + p.parse_args(['--deny-insecure', 'foo ,bar']).denyInsecure.should.eql(['foo', 'bar']); + }); + + it('should parse --allow-insecure & --deny-insecure from files', function () { + const parsed = p.parse_args([ + '--allow-insecure', ALLOW_FIXTURE, '--deny-insecure', DENY_FIXTURE + ]); + parsed.allowInsecure.should.eql(['feature1', 'feature2', 'feature3']); + parsed.denyInsecure.should.eql(['nofeature1', 'nofeature2', 'nofeature3']); + }); + + it('should allow a string for --drivers', function () { + p.parse_args(['--drivers', 'fake']).drivers.should.eql(['fake']); + }); + + + it('should allow multiple --drivers', function () { + p.parse_args(['--drivers', 'fake,phony']).drivers.should.eql(['fake', 'phony']); + }); }); - it('should take a driver name to install', function () { - const args = p.parse_args(['driver', 'install', 'foobar']); - args.subcommand.should.eql('driver'); - args.driverCommand.should.eql('install'); - args.driver.should.eql('foobar'); - should.not.exist(args.installType); - args.appiumHome.should.eql(DEFAULT_APPIUM_HOME); - args.json.should.eql(false); - }); - it('should allow json format', function () { - const args = p.parse_args(['driver', 'install', 'foobar', '--json']); - args.json.should.eql(true); - }); - it('should allow custom appium home', function () { - const args = p.parse_args(['driver', 'install', 'foobar', '--home', '/foo/bar']); - args.appiumHome.should.eql('/foo/bar'); - }); - it('should allow --source', function () { - for (const source of INSTALL_TYPES) { - const args = p.parse_args(['driver', 'install', 'foobar', '--source', source]); - args.installType.should.eql(source); - } - }); - it('should not allow unknown --source', function () { - (() => p.parse_args(['driver', 'install', 'fobar', '--source', 'blah'])).should.throw(); + + describe('extension arguments', function () { + beforeEach(async function () { + schema.resetSchema(); + // we have to require() here because babel will not compile stuff in node_modules + // (even if it's in the monorepo; there may be a way around this) + // anyway, if we do that, we need to use the `default` prop. + schema.registerSchema('driver', 'fake', require('@appium/fake-driver/build/lib/fake-driver-schema').default); + schema.finalizeSchema(); + p = await getParser(true); + }); + + it('should parse driver args correctly from a string', async function () { + // this test reads the actual schema provided by the fake driver. + // the config file corresponds to that schema. + // the command-line flags are derived also from the schema. + // the result should be that the parsed args should match the config file. + const {config} = await readConfigFile(resolveFixture('config', 'driver-fake.config.json')); + const fakeDriverArgs = {fake: {sillyWebServerPort: 1234, sillyWebServerHost: 'hey'}}; + const args = p.parse_args([ + '--driver-fake-silly-web-server-port', + fakeDriverArgs.fake.sillyWebServerPort, + '--driver-fake-silly-web-server-host', + fakeDriverArgs.fake.sillyWebServerHost + ]); + + args.driver.fake.should.eql(config.driver.fake); + }); + + describe('when user supplies invalid args', function () { + it('should error out', function () { + (() => p.parse_args(['--driver-fake-silly-web-server-port', 'foo'])).should.throw(/Value must be a number greater than or equal to 1; received foo/i); + }); + }); + + it('should not support --driver-args', function () { + (() => p.parse_args(['--driver-args', '/some/file.json'])).should.throw(/unrecognized arguments/i); + }); + + it('should not support --plugin-args', function () { + (() => p.parse_args(['--plugin-args', '/some/file.json'])).should.throw(/unrecognized arguments/i); + }); + }); }); - describe('uninstall', function () { - it('should not allow an empty argument list', function () { - (() => p.parse_args(['driver', 'uninstall'])).should.throw(); + + describe('Driver Parser', function () { + it('should not allow random sub-subcommands', function () { + (() => p.parse_args(['driver', 'foo'])).should.throw(); }); - it('should take a driver name to uninstall', function () { - const args = p.parse_args(['driver', 'uninstall', 'foobar']); - args.subcommand.should.eql('driver'); - args.driverCommand.should.eql('uninstall'); - args.driver.should.eql('foobar'); - args.appiumHome.should.eql(DEFAULT_APPIUM_HOME); - args.json.should.eql(false); + describe('list', function () { + it('should allow an empty argument list', function () { + const args = p.parse_args(['driver', 'list']); + args.subcommand.should.eql('driver'); + args.driverCommand.should.eql('list'); + args.showInstalled.should.eql(false); + args.showUpdates.should.eql(false); + args.json.should.eql(false); + }); + it('should allow json format', function () { + const args = p.parse_args(['driver', 'list', '--json']); + args.json.should.eql(true); + }); + it('should allow --installed', function () { + const args = p.parse_args(['driver', 'list', '--installed']); + args.showInstalled.should.eql(true); + }); + it('should allow --updates', function () { + const args = p.parse_args(['driver', 'list', '--updates']); + args.showUpdates.should.eql(true); + }); }); - it('should allow json format', function () { - const args = p.parse_args(['driver', 'uninstall', 'foobar', '--json']); - args.json.should.eql(true); + describe('install', function () { + it('should not allow an empty argument list', function () { + (() => p.parse_args(['driver', 'install'])).should.throw(); + }); + it('should take a driver name to install', function () { + const args = p.parse_args(['driver', 'install', 'foobar']); + args.subcommand.should.eql('driver'); + args.driverCommand.should.eql('install'); + args.driver.should.eql('foobar'); + should.not.exist(args.installType); + args.json.should.eql(false); + }); + it('should allow json format', function () { + const args = p.parse_args(['driver', 'install', 'foobar', '--json']); + args.json.should.eql(true); + }); + it('should allow --source', function () { + for (const source of INSTALL_TYPES) { + const args = p.parse_args(['driver', 'install', 'foobar', '--source', source]); + args.installType.should.eql(source); + } + }); + it('should not allow unknown --source', function () { + (() => p.parse_args(['driver', 'install', 'fobar', '--source', 'blah'])).should.throw(); + }); }); - it('should allow custom appium home', function () { - const args = p.parse_args(['driver', 'uninstall', 'foobar', '--home', '/foo/bar']); - args.appiumHome.should.eql('/foo/bar'); + describe('uninstall', function () { + it('should not allow an empty argument list', function () { + (() => p.parse_args(['driver', 'uninstall'])).should.throw(); + }); + it('should take a driver name to uninstall', function () { + const args = p.parse_args(['driver', 'uninstall', 'foobar']); + args.subcommand.should.eql('driver'); + args.driverCommand.should.eql('uninstall'); + args.driver.should.eql('foobar'); + args.json.should.eql(false); + }); + it('should allow json format', function () { + const args = p.parse_args(['driver', 'uninstall', 'foobar', '--json']); + args.json.should.eql(true); + }); }); - }); - describe('update', function () { - it('should not allow an empty argument list', function () { - (() => p.parse_args(['driver', 'update'])).should.throw(); + describe('update', function () { + it('should not allow an empty argument list', function () { + (() => p.parse_args(['driver', 'update'])).should.throw(); + }); + it('should take a driver name to update', function () { + const args = p.parse_args(['driver', 'update', 'foobar']); + args.subcommand.should.eql('driver'); + args.driverCommand.should.eql('update'); + args.driver.should.eql('foobar'); + args.json.should.eql(false); + }); + it('should allow json format', function () { + const args = p.parse_args(['driver', 'update', 'foobar', '--json']); + args.json.should.eql(true); + }); }); - it('should take a driver name to update', function () { - const args = p.parse_args(['driver', 'update', 'foobar']); - args.subcommand.should.eql('driver'); - args.driverCommand.should.eql('update'); - args.driver.should.eql('foobar'); - args.appiumHome.should.eql(DEFAULT_APPIUM_HOME); - args.json.should.eql(false); - }); - it('should allow json format', function () { - const args = p.parse_args(['driver', 'update', 'foobar', '--json']); - args.json.should.eql(true); - }); - it('should allow custom appium home', function () { - const args = p.parse_args(['driver', 'update', 'foobar', '--home', '/foo/bar']); - args.appiumHome.should.eql('/foo/bar'); - }); - }); - describe('run', function () { - it('should not allow an empty driver argument list', function () { - (() => p.parse_args(['driver', 'run'])).should.throw(); - }); - it('should not allow no driver scriptName', function () { - (() => p.parse_args(['driver', 'run', 'foo'])).should.throw(); - }); - it('should take a driverName and scriptName to run', function () { - const args = p.parse_args(['driver', 'run', 'foo', 'bar']); - args.subcommand.should.eql('driver'); - args.driverCommand.should.eql('run'); - args.driver.should.eql('foo'); - args.scriptName.should.eql('bar'); - args.appiumHome.should.eql(DEFAULT_APPIUM_HOME); - args.json.should.eql(false); - }); - it('should allow json format for driver', function () { - const args = p.parse_args(['driver', 'run', 'foo', 'bar', '--json']); - args.json.should.eql(true); - }); - it('should not allow an empty plugin argument list', function () { - (() => p.parse_args(['plugin', 'run'])).should.throw(); - }); - it('should not allow no plugin scriptName', function () { - (() => p.parse_args(['plugin', 'run', 'foo'])).should.throw(); - }); - it('should take a pluginName and scriptName to run', function () { - const args = p.parse_args(['plugin', 'run', 'foo', 'bar']); - args.subcommand.should.eql('plugin'); - args.pluginCommand.should.eql('run'); - args.plugin.should.eql('foo'); - args.scriptName.should.eql('bar'); - args.appiumHome.should.eql(DEFAULT_APPIUM_HOME); - args.json.should.eql(false); - }); - it('should allow json format for plugin', function () { - const args = p.parse_args(['plugin', 'run', 'foo', 'bar', '--json']); - args.json.should.eql(true); + describe('run', function () { + it('should not allow an empty driver argument list', function () { + (() => p.parse_args(['driver', 'run'])).should.throw(); + }); + it('should not allow no driver scriptName', function () { + (() => p.parse_args(['driver', 'run', 'foo'])).should.throw(); + }); + it('should take a driverName and scriptName to run', function () { + const args = p.parse_args(['driver', 'run', 'foo', 'bar']); + args.subcommand.should.eql('driver'); + args.driverCommand.should.eql('run'); + args.driver.should.eql('foo'); + args.scriptName.should.eql('bar'); + args.json.should.eql(false); + }); + it('should allow json format for driver', function () { + const args = p.parse_args(['driver', 'run', 'foo', 'bar', '--json']); + args.json.should.eql(true); + }); + it('should not allow an empty plugin argument list', function () { + (() => p.parse_args(['plugin', 'run'])).should.throw(); + }); + it('should not allow no plugin scriptName', function () { + (() => p.parse_args(['plugin', 'run', 'foo'])).should.throw(); + }); + it('should take a pluginName and scriptName to run', function () { + const args = p.parse_args(['plugin', 'run', 'foo', 'bar']); + args.subcommand.should.eql('plugin'); + args.pluginCommand.should.eql('run'); + args.plugin.should.eql('foo'); + args.scriptName.should.eql('bar'); + args.json.should.eql(false); + }); + it('should allow json format for plugin', function () { + const args = p.parse_args(['plugin', 'run', 'foo', 'bar', '--json']); + args.json.should.eql(true); + }); }); }); }); diff --git a/packages/appium/test/plugin-e2e-specs.js b/packages/appium/test/plugin-e2e-specs.js index 1978699a8..52c95c919 100644 --- a/packages/appium/test/plugin-e2e-specs.js +++ b/packages/appium/test/plugin-e2e-specs.js @@ -155,7 +155,7 @@ describe('FakePlugin', function () { let server = null; before(async function () { // then start server if we need to - const args = {...baseArgs, pluginArgs: FAKE_PLUGIN_ARGS}; + const args = {...baseArgs, plugin: FAKE_PLUGIN_ARGS}; server = await appiumServer(args); }); after(async function () { diff --git a/packages/appium/test/schema-specs.js b/packages/appium/test/schema-specs.js new file mode 100644 index 000000000..a674e6a1c --- /dev/null +++ b/packages/appium/test/schema-specs.js @@ -0,0 +1,435 @@ +//@ts-check +import rewiremock from 'rewiremock/node'; +import sinon from 'sinon'; +import appiumConfigSchema from '../lib/appium-config-schema'; +import flattenedSchemaFixture from './fixtures/flattened-schema'; +import defaultArgsFixture from './fixtures/default-args'; + +const expect = require('chai').expect; + +describe('schema', function () { + /** @type {import('../lib/schema')} */ + let schema; + /** @type {import('sinon').SinonSandbox} */ + let sandbox; + + let mocks; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + + mocks = { + '../lib/extension-config': { + APPIUM_HOME: '/path/to/appium/home', + SCHEMA_ID_EXTENSION_PROPERTY: 'automationName', + }, + + 'resolve-from': sandbox.stub(), + + '@sidvind/better-ajv-errors': sandbox.stub(), + }; + + schema = rewiremock.proxy(() => require('../lib/schema'), mocks); + + schema.resetSchema(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('registerSchema()', function () { + describe('error conditions', function () { + describe('when provided no parameters', function () { + it('should throw a TypeError', function () { + // @ts-ignore + expect(() => schema.registerSchema()).to.throw( + TypeError, + 'Expected nonempty extension type, extension name and schema parameters', + ); + }); + }); + + describe('when provided `type` and `name`, but not `schema`', function () { + it('should throw a TypeError', function () { + expect(() => + // @ts-ignore + schema.registerSchema('driver', 'whoopeee'), + ).to.throw( + TypeError, + 'Expected nonempty extension type, extension name and schema parameters', + ); + }); + }); + + describe('when provided `type`, a nonempty `name`, but an empty `schema`', function () { + it('should throw a TypeError', function () { + expect(() => + schema.registerSchema('driver', 'whoopeee', {}), + ).to.throw( + TypeError, + 'Expected nonempty extension type, extension name and schema parameters', + ); + }); + }); + + describe('when provided `type` and nonempty `schema`, but no `name`', function () { + it('should throw a TypeError', function () { + expect(() => + // @ts-ignore + schema.registerSchema('driver', undefined, { + title: 'whoopeee', + }), + ).to.throw( + TypeError, + 'Expected nonempty extension type, extension name and schema parameters', + ); + }); + }); + + describe('when provided a `type` and nonempty `name`, but an invalid `schema`', function () { + it('should throw', function () { + const schemaObject = [45]; + expect(() => + schema.registerSchema('driver', 'whoopeee', schemaObject), + ).to.throw(/schema is invalid/i); + }); + }); + + describe('when schema previously registered', function () { + it('should throw', function () { + const schemaObject = {title: 'whoopee'}; + schema.registerSchema('driver', 'whoopee', schemaObject); + expect(() => + schema.registerSchema('driver', 'whoopee', schemaObject), + ).to.throw(Error, /conflicts with an existing schema/); + }); + }); + }); + + describe('when provided a nonempty `type`, `schema` and `name`', function () { + it('should register the schema', function () { + const schemaObject = {title: 'whoopee'}; + expect(() => + schema.registerSchema('driver', 'whoopee', schemaObject), + ).not.to.throw(); + }); + + describe('when the `name` is not unique but `type` is', function () { + it('should register both', function () { + const schema1 = {title: 'pro-skub'}; + const schema2 = {title: 'anti-skub'}; + schema.registerSchema('driver', 'skub', schema1); + expect(() => + schema.registerSchema('plugin', 'skub', schema2), + ).not.to.throw(); + }); + }); + }); + }); + + describe('getSchema()', function () { + describe('when schema not yet compiled', function () { + it('should throw', function () { + expect(() => schema.getSchema()).to.throw( + Error, + /schema not yet compiled/i, + ); + }); + }); + + describe('when schema compiled', function () { + beforeEach(function () { + schema.finalizeSchema(); + }); + + it('should return a schema', function () { + expect(schema.getSchema()).to.eql(appiumConfigSchema); + }); + }); + }); + + describe('getDefaultsFromSchema()', function () { + describe('when schema not yet compiled', function () { + it('should throw', function () { + expect(() => schema.getDefaultsFromSchema()).to.throw( + Error, + /schema not yet compiled/i, + ); + }); + }); + + describe('when schema already compiled', function () { + it('should return a Record object with only defined default values', function () { + schema.finalizeSchema(); + const defaults = schema.getDefaultsFromSchema(); + expect(defaults).to.deep.equal(defaultArgsFixture); + }); + + describe('when extension schemas include defaults', function () { + it('should return a Record object containing defaults for the extensions', function () { + const extData = { + installPath: 'fixtures', + pkgName: 'some-pkg', + schema: 'driver.schema.js', + automationName: 'stuff', + }; + mocks['resolve-from'].returns( + require.resolve('./fixtures/driver.schema.js'), + ); + schema.readExtensionSchema('driver', 'stuff', extData); + schema.finalizeSchema(); + const defaults = schema.getDefaultsFromSchema(); + expect(defaults).to.have.property('driver-stuff-answer', 50); + }); + }); + }); + }); + + describe('flattenSchema()', function () { + describe('when schema not yet compiled', function () { + it('should throw', function () { + expect(() => schema.flattenSchema()).to.throw( + Error, + /schema not yet compiled/i, + ); + }); + }); + + describe('when schema compiled', function () { + beforeEach(function () { + schema.resetSchema(); + schema.finalizeSchema(); + expect(schema.hasRegisteredSchema('driver', 'stuff')).to.be.false; + // sanity check + // expect(schema.getSchema().properties.driver.properties).to.be.empty; + }); + + it('should flatten a schema', function () { + expect(schema.flattenSchema()).to.deep.equal(flattenedSchemaFixture); + }); + }); + + describe('when extensions provide schemas', function () { + let expected; + + beforeEach(function () { + mocks['resolve-from'].returns( + require.resolve('@appium/fake-driver/build/lib/fake-driver-schema'), + ); + + schema.readExtensionSchema('driver', 'fake', { + installPath: 'derp', + schema: 'herp', + automationName: 'Fake', + pkgName: '@appium/fake-driver', + }); + schema.finalizeSchema(); + // sanity check + expect(schema.getSchema().properties.driver.properties.fake).to.exist; + + // these props would be added by the fake-driver extension + expected = { + ...flattenedSchemaFixture, + 'driver-fake-sillyWebServerHost': { + default: 'sillyhost', + description: 'The host to use for the fake web server', + type: 'string', + }, + 'driver-fake-sillyWebServerPort': { + description: 'The port to use for the fake web server', + type: 'integer', + minimum: 1, + maximum: 65535, + }, + }; + }); + + it('should flatten a schema', function () { + expect(schema.flattenSchema()).to.deep.equal(expected); + }); + }); + }); + + describe('readExtensionSchema()', function () { + /** @type {import('../lib/schema').ExtData} */ + let extData; + const extName = 'stuff'; + + describe('driver', function () { + + beforeEach(function () { + extData = { + installPath: 'fixtures', + pkgName: 'some-pkg', + schema: 'driver.schema.js', + }; + mocks['resolve-from'].returns( + require.resolve('./fixtures/driver.schema.js'), + ); + }); + + describe('error conditions', function () { + describe('when the extension data is missing `installPath`', function () { + it('should throw', function () { + // @ts-ignore + delete extData.installPath; + expect(() => + schema.readExtensionSchema('driver', extName, extData), + ).to.throw(Error, 'Incomplete extension data'); + }); + }); + + describe('when the extension data is missing `pkgName`', function () { + it('should throw', function () { + // @ts-ignore + delete extData.pkgName; + expect(() => + schema.readExtensionSchema('driver', extName, extData), + ).to.throw(Error, 'Incomplete extension data'); + }); + }); + + describe('when the extension data is missing `schema`', function () { + it('should throw', function () { + delete extData.schema; + expect(() => + schema.readExtensionSchema('driver', extName, extData), + ).to.throw(Error, 'Incomplete extension data'); + }); + }); + + describe('when the `extName` was not provided', function () { + it('should throw', function () { + expect(() => + schema.readExtensionSchema('driver', undefined, extData), + ).to.throw(Error, 'Incomplete extension data'); + }); + }); + }); + + describe('when the extension schema has already been registered', function () { + it('should not attempt to re-register the schema', function () { + schema.readExtensionSchema('driver', extName, extData); + mocks['resolve-from'].reset(); + schema.readExtensionSchema('driver', extName, extData); + expect(mocks['resolve-from']).not.to.have.been.called; + }); + }); + + describe('when the extension schema has not yet been registered', function () { + it('should resolve and load the extension schema file', function () { + schema.readExtensionSchema('driver', extName, extData); + + // we don't have access to the schema registration cache directly, so this is as close as we can get. + expect(mocks['resolve-from']).to.have.been.calledOnce; + }); + }); + }); + + describe('plugin', function () { + const extName = 'stuff'; + /** @type {import('../lib/schema').ExtData} */ + let extData; + + beforeEach(function () { + extData = { + installPath: 'fixtures', + pkgName: 'some-pkg', + schema: 'plugin.schema.js', + }; + mocks['resolve-from'].returns( + require.resolve('./fixtures/plugin.schema.js'), + ); + }); + + describe('error conditions', function () { + describe('when the extension data is missing `installPath`', function () { + it('should throw', function () { + // @ts-ignore + delete extData.installPath; + expect(() => + schema.readExtensionSchema('plugin', extName, extData), + ).to.throw(Error, 'Incomplete extension data'); + }); + }); + + describe('when the extension data is missing `pkgName`', function () { + it('should throw', function () { + // @ts-ignore + delete extData.pkgName; + expect(() => + schema.readExtensionSchema('plugin', extName, extData), + ).to.throw(Error, 'Incomplete extension data'); + }); + }); + + describe('when the extension data is missing `schema`', function () { + it('should throw', function () { + delete extData.schema; + expect(() => + schema.readExtensionSchema('plugin', extName, extData), + ).to.throw(Error, 'Incomplete extension data'); + }); + }); + + describe('when the `extName` was not provided', function () { + it('should throw', function () { + expect(() => + schema.readExtensionSchema('plugin', undefined, extData), + ).to.throw(Error, 'Incomplete extension data'); + }); + }); + }); + + describe('when the extension schema has already been registered', function () { + it('should not attempt to re-register the schema', function () { + schema.readExtensionSchema('plugin', extName, extData); + mocks['resolve-from'].reset(); + schema.readExtensionSchema('plugin', extName, extData); + expect(mocks['resolve-from']).not.to.have.been.called; + }); + }); + + describe('when the extension schema has not yet been registered', function () { + it('should resolve and load the extension schema file', function () { + schema.readExtensionSchema('plugin', extName, extData); + + // we don't have access to the schema registration cache directly, so this is as close as we can get. + expect(mocks['resolve-from']).to.have.been.calledOnce; + }); + }); + }); + }); + + describe('isFinalized()', function () { + describe('when the schema is finalized', function () { + it('should return true', function () { + schema.finalizeSchema(); + expect(schema.isFinalized()).to.be.true; + }); + }); + + describe('when the schema is not finalized', function () { + it('should return false', function () { + schema.resetSchema(); + expect(schema.isFinalized()).to.be.false; + }); + }); + }); +}); + +/** + * @template P,R + * @typedef {import('sinon').SinonStub} SinonStub + */ + +/** + * @typedef {import('ajv').default['addSchema']} AjvAddSchema + * @typedef {import('ajv').default['getSchema']} AjvGetSchema + * @typedef {import('ajv').default['validateSchema']} AjvValidateSchema + */ + +/** + * @typedef {import('ajv/dist/core').AnyValidateFunction} AnyValidateFunction + */ diff --git a/packages/appium/test/utils-specs.js b/packages/appium/test/utils-specs.js index 7b321ebb6..b1c05ee5b 100644 --- a/packages/appium/test/utils-specs.js +++ b/packages/appium/test/utils-specs.js @@ -1,12 +1,10 @@ import { parseCapsForInnerDriver, insertAppiumPrefixes, pullSettings, - removeAppiumPrefixes, getExtensionArgs, validateExtensionArgs + removeAppiumPrefixes, validateExtensionArgs } from '../lib/utils'; import { BASE_CAPS, W3C_CAPS } from './helpers'; import _ from 'lodash'; -const FAKE_DRIVER_NAME = `fake`; - describe('utils', function () { describe('parseCapsForInnerDriver()', function () { it('should return an error if only JSONWP provided', function () { @@ -211,19 +209,6 @@ describe('utils', function () { }); }); - describe('getExtensionArgs()', function () { - it('should return an empty object if extension not in args', function () { - getExtensionArgs({foo: {bar: 1234}}, FAKE_DRIVER_NAME).should.eql({}); - }); - it('should return the args for an extension', function () { - getExtensionArgs({foo: {bar: 1234}}, 'foo').should.eql({bar: 1234}); - }); - it('should throw an error if arg is not a plain object', function () { - const fakeDriverArgs = {fake: 1234}; - (() => getExtensionArgs(fakeDriverArgs, FAKE_DRIVER_NAME,)).should.throw(); - }); - }); - describe('validateExtensionArgs', function () { const webkitDebugProxyPort = 22222; const wdaLocalPort = 8000; diff --git a/packages/appium/types/appium-config.d.ts b/packages/appium/types/appium-config.d.ts new file mode 100644 index 000000000..025803d8c --- /dev/null +++ b/packages/appium/types/appium-config.d.ts @@ -0,0 +1,201 @@ +/* tslint:disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * IP address to listen on + */ +export type AddressConfig = string; +/** + * Whether the Appium server should allow web browser connections from any host + */ +export type AllowCorsConfig = boolean; +/** + * Set which insecure features are allowed to run in this server's sessions. Features are defined on a driver level; see documentation for more details. Note that features defined via "deny-insecure" will be disabled, even if also listed here. If string, a path to a text file containing policy or a comma-delimited list. + */ +export type AllowInsecureConfig = string[] | string; +/** + * Base path to use as the prefix for all webdriver routes running on the server + */ +export type BasePathConfig = string; +/** + * Callback IP address (default: same as "address") + */ +export type CallbackAddressConfig = string; +/** + * Callback port (default: same as "port") + */ +export type CallbackPortConfig = number; +/** + * Add exaggerated spacing in logs to help with visual inspection + */ +export type DebugLogSpacingConfig = boolean; +/** + * Set the default desired capabilities, which will be set on each session unless overridden by received capabilities. If a string, a path to a JSON file containing the capabilities, or raw JSON. + */ +export type DefaultCapabilitiesConfig = + | { + [k: string]: unknown; + } + | string; +/** + * Set which insecure features are not allowed to run in this server's sessions. Features are defined on a driver level; see documentation for more details. Features listed here will not be enabled even if also listed in "allow-insecure", and even if "relaxed-security" is enabled. If string, a path to a text file containing policy or a comma-delimited list. + */ +export type DenyInsecureConfig = string[] | string; +/** + * A list of drivers to activate. By default, all installed drivers will be activated. + */ +export type DriversConfig = string | string[]; +/** + * Number of seconds the Appium server should apply as both the keep-alive timeout and the connection timeout for all requests. A value of 0 disables the timeout. + */ +export type KeepAliveTimeoutConfig = number; +/** + * Use local timezone for timestamps + */ +export type LocalTimezoneConfig = boolean; +/** + * Also send log output to this file + */ +export type LogConfig = string; +/** + * One or more log filtering rules + */ +export type LogFiltersConfig = string[]; +/** + * Log level (console[:file]) + */ +export type LogLevelConfig = + | "info" + | "info:debug" + | "info:info" + | "info:warn" + | "info:error" + | "warn" + | "warn:debug" + | "warn:info" + | "warn:warn" + | "warn:error" + | "error" + | "error:debug" + | "error:info" + | "error:warn" + | "error:error" + | "debug" + | "debug:debug" + | "debug:info" + | "debug:warn" + | "debug:error"; +/** + * Do not use color in console output + */ +export type LogNoColorsConfig = boolean; +/** + * Show timestamps in console output + */ +export type LogTimestampConfig = boolean; +/** + * Add long stack traces to log entries. Recommended for debugging only. + */ +export type LongStacktraceConfig = boolean; +/** + * Do not check that needed files are readable and/or writable + */ +export type NoPermsCheckConfig = boolean; +/** + * Path to configuration JSON file to register Appium as a node with Selenium Grid 3; otherwise the configuration itself + */ +export type NodeconfigConfig = + | { + [k: string]: unknown; + } + | string; +/** + * A list of plugins to activate. To activate all plugins, use the single string "all" + */ +export type PluginsConfig = string | string[]; +/** + * Port to listen on + */ +export type PortConfig = number; +/** + * Disable additional security checks, so it is possible to use some advanced features, provided by drivers supporting this option. Only enable it if all the clients are in the trusted network and it's not the case if a client could potentially break out of the session sandbox. Specific features can be overridden by using "deny-insecure" + */ +export type RelaxedSecurityConfig = boolean; +/** + * Enables session override (clobbering) + */ +export type SessionOverrideConfig = boolean; +/** + * Cause sessions to fail if desired caps are sent in that Appium does not recognize as valid for the selected device + */ +export type StrictCapsConfig = boolean; +/** + * Absolute path to directory Appium can use to manage temp files. Defaults to C:\Windows\Temp on Windows and /tmp otherwise. + */ +export type TmpConfig = string; +/** + * Absolute path to directory Appium can use to save iOS instrument traces; defaults to /appium-instruments + */ +export type TraceDirConfig = string; +/** + * Also send log output to this http listener + */ +export type WebhookConfig = string; + +/** + * A schema for Appium configuration files + */ +export interface AppiumConfiguration { + server?: ServerConfig; + driver?: DriverConfig; + plugin?: PluginConfig; +} +/** + * Configuration when running Appium as a server + */ +export interface ServerConfig { + address?: AddressConfig; + "allow-cors"?: AllowCorsConfig; + "allow-insecure"?: AllowInsecureConfig; + "base-path"?: BasePathConfig; + "callback-address"?: CallbackAddressConfig; + "callback-port"?: CallbackPortConfig; + "debug-log-spacing"?: DebugLogSpacingConfig; + "default-capabilities"?: DefaultCapabilitiesConfig; + "deny-insecure"?: DenyInsecureConfig; + drivers?: DriversConfig; + "keep-alive-timeout"?: KeepAliveTimeoutConfig; + "local-timezone"?: LocalTimezoneConfig; + log?: LogConfig; + "log-filters"?: LogFiltersConfig; + "log-level"?: LogLevelConfig; + "log-no-colors"?: LogNoColorsConfig; + "log-timestamp"?: LogTimestampConfig; + "long-stacktrace"?: LongStacktraceConfig; + "no-perms-check"?: NoPermsCheckConfig; + nodeconfig?: NodeconfigConfig; + plugins?: PluginsConfig; + port?: PortConfig; + "relaxed-security"?: RelaxedSecurityConfig; + "session-override"?: SessionOverrideConfig; + "strict-caps"?: StrictCapsConfig; + tmp?: TmpConfig; + "trace-dir"?: TraceDirConfig; + webhook?: WebhookConfig; +} +/** + * Driver-specific configuration. Keys should correspond to driver package names + */ +export interface DriverConfig { + [k: string]: unknown; +} +/** + * Plugin-specific configuration. Keys should correspond to plugin package names + */ +export interface PluginConfig { + [k: string]: unknown; +} diff --git a/packages/appium/types/types.d.ts b/packages/appium/types/types.d.ts new file mode 100644 index 000000000..87e0fc980 --- /dev/null +++ b/packages/appium/types/types.d.ts @@ -0,0 +1,27 @@ +import {AppiumConfiguration} from './appium-config'; + +/* + * Utility types + */ + +export type CamelCase = + S extends `${infer P1}_${infer P2}${infer P3}` + ? `${Lowercase}${Uppercase}${CamelCase}` + : Lowercase; + +export type ObjectToCamel = { + [K in keyof T as CamelCase]: T[K] extends Record + ? KeysToCamelCase + : T[K]; +}; + +export type KeysToCamelCase = { + [K in keyof T as CamelCase]: T[K] extends Array + ? KeysToCamelCase[] + : ObjectToCamel; +}; + +export type NormalizedAppiumConfiguration = + KeysToCamelCase; + +export type AppiumConfiguration = AppiumConfiguration; diff --git a/sample-code/appium.config.sample.json b/sample-code/appium.config.sample.json new file mode 100644 index 000000000..5743427a6 --- /dev/null +++ b/sample-code/appium.config.sample.json @@ -0,0 +1,46 @@ +{ + "server": { + "drivers": ["foo", "bar"], + "plugins": ["baz", "quux"], + "allow-cors": true, + "address": "127.0.0.1", + "port": 4723, + "base-path": "/", + "keep-alive-timeout": 600, + "callback-address": "127.0.0.1", + "callback-port": 4723, + "session-override": false, + "log": "/tmp/appium.log", + "log-level": "info", + "log-timestamp": true, + "local-timezone": true, + "log-no-colors": false, + "webhook": "https://some-url.com", + "nodeconfig": { + "key": "value" + }, + "no-perms-check": false, + "strict-caps": true, + "tmp": "/tmp", + "trace-dir": "/tmp/appium-instruments", + "debug-log-spacing": true, + "long-stacktrace": false, + "default-capabilities": { + "key": "value" + }, + "relaxed-security": false, + "allow-insecure": ["foo", "bar"], + "deny-insecure": ["baz", "quux"] + }, + "driver": { + "xcuitest": { + "key": "value" + } + }, + "plugin": { + "images": { + "key": "value" + } + } +} + diff --git a/sample-code/appium.config.sample.yaml b/sample-code/appium.config.sample.yaml new file mode 100644 index 000000000..41db1fdf0 --- /dev/null +++ b/sample-code/appium.config.sample.yaml @@ -0,0 +1,44 @@ +server: + drivers: + - foo + - bar + plugins: + - baz + - quux + allow-cors: true + address: 127.0.0.1 + port: 4723 + base-path: "/" + keep-alive-timeout: 600 + callback-address: 127.0.0.1 + callback-port: 4723 + session-override: false + log: "/tmp/appium.log" + log-level: info + log-timestamp: true + local-timezone: true + log-no-colors: false + webhook: https://some-url.com + nodeconfig: + key: value + no-perms-check: false + strict-caps: true + tmp: "/tmp" + trace-dir: "/tmp/appium-instruments" + debug-log-spacing: true + long-stacktrace: false + default-capabilities: + key: value + relaxed-security: false + allow-insecure: + - foo + - bar + deny-insecure: + - baz + - quux +driver: + xcuitest: + key: value +plugin: + images: + key: value diff --git a/scripts/generate-schema-declarations.js b/scripts/generate-schema-declarations.js new file mode 100644 index 000000000..0d88bebb8 --- /dev/null +++ b/scripts/generate-schema-declarations.js @@ -0,0 +1,38 @@ +/* eslint-disable no-console */ +'use strict'; + +/* + * This module reads in the config file JSON schema and outputs a TypeScript declaration file (`.d.ts`). + */ + +const path = require('path'); +const {compileFromFile} = require('json-schema-to-typescript'); +const {fs} = require('../packages/support'); + +const SCHEMA_PATH = require.resolve( + '../packages/appium/build/lib/appium-config.schema.json', +); + +const DECLARATIONS_PATH = path.join( + __dirname, + '..', + 'packages', + 'appium', + 'types', + 'appium-config.d.ts', +); + +async function main () { + try { + const ts = await compileFromFile(SCHEMA_PATH); + await fs.writeFile(DECLARATIONS_PATH, ts); + console.log(`wrote to ${DECLARATIONS_PATH}`); + } catch (err) { + console.error(err); + process.exitCode = 1; + } +} + +if (require.main === module) { + main(); +}