appium
Version:
Automation for Apps.
229 lines (205 loc) • 8.2 kB
JavaScript
import betterAjvErrors from '@sidvind/better-ajv-errors';
import {lilconfig} from 'lilconfig';
import _ from 'lodash';
import yaml from 'yaml';
import {getSchema, validate} from './schema/schema';
/**
* lilconfig loader to handle `.yaml` files
* @type {import('lilconfig').LoaderSync}
*/
function yamlLoader(filepath, content) {
try {
return yaml.parse(content);
} catch (e) {
throw new Error(`The YAML config at '${filepath}' cannot be loaded. Original error: ${e.message}`);
}
}
/**
* 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<string,RawJson>}
*/
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) {
rawConfig.set(filepath, content);
try {
return JSON.parse(content);
} catch (e) {
throw new Error(`The JSON config at '${filepath}' cannot be loaded. Original error: ${e.message}`);
}
}
/**
* Loads a config file from an explicit path
* @param {LilconfigAsyncSearcher} lc - lilconfig instance
* @param {string} filepath - Path to config file
* @returns {Promise<import('lilconfig').LilconfigResult>}
*/
async function loadConfigFile(lc, filepath) {
try {
// removing "await" will cause any rejection to _not_ be caught in this block!
return await lc.load(filepath);
} catch (/** @type {unknown} */ err) {
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
/** @type {NodeJS.ErrnoException} */ (
err
).message = `Config file not found at user-provided path: ${filepath}`;
throw err;
} else if (err instanceof SyntaxError) {
// generally invalid JSON
err.message = `Config file at user-provided path ${filepath} is invalid:\n${err.message}`;
throw err;
}
throw err;
}
}
/**
* Searches for a config file
* @param {LilconfigAsyncSearcher} lc - lilconfig instance
* @returns {Promise<import('lilconfig').LilconfigResult>}
*/
async function searchConfigFile(lc) {
return await lc.search();
}
/**
* 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 {ReadConfigFileResult['config']|any} [config] -
* Configuration & metadata
* @param {FormatConfigErrorsOptions} [opts]
* @throws {TypeError} If `errors` is empty
* @returns {string}
*/
export function formatErrors(errors = [], config = {}, opts = {}) {
if (errors && !errors.length) {
throw new TypeError('Array of errors must be non-empty');
}
return betterAjvErrors(getSchema(opts.schemaId), config, errors, {
json: opts.json,
format: 'cli',
});
}
/**
* 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<ReadConfigFileResult>} 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,
},
packageProp: 'appiumConfig',
});
const result = filepath ? await loadConfigFile(lc, filepath) : await searchConfigFile(lc);
if (result?.filepath && !result?.isEmpty) {
const {pretty = true} = opts;
try {
let configResult;
const errors = validate(result.config);
if (_.isEmpty(errors)) {
configResult = {...result, errors};
} else {
const reason = formatErrors(errors, result.config, {
json: rawConfig.get(result.filepath),
pretty,
});
configResult = reason ? {...result, errors, reason} : {...result, errors};
}
// normalize (to camel case) all top-level property names of the config file
configResult.config = normalizeConfig(/** @type {AppiumConfig} */ (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 {AppiumConfig} config - Configuration object
* @returns {NormalizedAppiumConfig} New object with camel-cased keys (or `dest` keys).
*/
export function normalizeConfig(config) {
const schema = getSchema();
/**
* @param {AppiumConfig} config
* @param {string} [section] - Keypath (lodash `_.get()` style) to section of config. If omitted, assume root Appium config schema
* @todo Rewrite as a loop
* @returns Normalized section of config
*/
const normalize = (config, section) => {
const obj = _.isUndefined(section) ? config : _.get(config, section, config);
const mappedObj = _.mapKeys(obj, (__, prop) =>
_.get(schema, `properties.server.properties[${prop}].appiumCliDest`, _.camelCase(prop))
);
return _.mapValues(mappedObj, (value, property) => {
const nextSection = section ? `${section}.${property}` : property;
return isSchemaTypeObject(schema.properties?.[property])
? 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 || schema?.type === 'object');
return normalize(config);
}
/**
* Result of calling {@link readConfigFile}.
* @typedef 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 {NormalizedAppiumConfig} [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 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.
*/
/**
* This is an `AsyncSearcher` which is inexplicably _not_ exported by the `lilconfig` type definition.
* @typedef {ReturnType<import('lilconfig')["lilconfig"]>} LilconfigAsyncSearcher
*/
/**
* The contents of an Appium config file. Generated from schema
* @typedef {import('@appium/types').AppiumConfig} AppiumConfig
*/
/**
* The contents of an Appium config file with camelcased property names (and using `appiumCliDest` value if present). Generated from {@link AppiumConfig}
* @typedef {import('@appium/types').NormalizedAppiumConfig} NormalizedAppiumConfig
*/
/**
* The string should be a raw JSON string.
* @typedef {string} RawJson
*/
/**
* Options for {@link formatErrors}.
* @typedef 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
* @property {string} [schemaId] - Specific ID of a prop; otherwise entire schema
*/