appium
Version:
Automation for Apps.
390 lines (353 loc) • 11.7 kB
JavaScript
/* eslint-disable no-console */
import _ from 'lodash';
import {system, fs, npm} from '@appium/support';
import axios from 'axios';
import {exec} from 'teen_process';
import * as semver from 'semver';
import os from 'node:os';
import {npmPackage} from './utils';
import B from 'bluebird';
import {getDefaultsForSchema, getAllArgSpecs} from './schema/schema';
export const APPIUM_VER = npmPackage.version;
const ENGINES = /** @type {Record<string,string>} */ (npmPackage.engines);
const MIN_NODE_VERSION = ENGINES.node;
export const rootDir = fs.findRoot(__dirname);
const GIT_BINARY = `git${system.isWindows() ? '.exe' : ''}`;
const GITHUB_API = 'https://api.github.com/repos/appium/appium';
/**
* @type {import('appium/types').BuildInfo}
*/
const BUILD_INFO = {
version: APPIUM_VER,
};
function getNodeVersion() {
return /** @type {import('semver').SemVer} */ (semver.coerce(process.version));
}
/**
* @param {boolean} [useGithubApiFallback]
* @returns {Promise<void>}
*/
export async function updateBuildInfo(useGithubApiFallback = false) {
const sha = await getGitRev(useGithubApiFallback);
if (!sha) {
return;
}
BUILD_INFO['git-sha'] = sha;
const buildTimestamp = await getGitTimestamp(sha, useGithubApiFallback);
if (buildTimestamp) {
BUILD_INFO.built = buildTimestamp;
}
}
/** @type {() => Promise<string?>} */
const getFullGitPath = _.memoize(async function getFullGitPath() {
try {
return await fs.which(GIT_BINARY);
} catch {
return null;
}
});
/**
* Prints server debug into into the console.
*
* @param {{
* driverConfig: import('./extension/driver-config').DriverConfig,
* pluginConfig: import('./extension/plugin-config').PluginConfig,
* appiumHome: string
* }} info
* @returns {Promise<void>}
*/
export async function showDebugInfo({driverConfig, pluginConfig, appiumHome}) {
const getNpmVersion = async () => {
const {stdout} = await npm.exec('--version', [], {cwd: process.cwd()});
return _.trim(stdout);
};
const findNpmLocation = async () => await fs.which(system.isWindows() ? 'npm.cmd' : 'npm');
const [npmVersion, npmLocation] = await B.all([
...([getNpmVersion, findNpmLocation].map((f) => getSafeResult(f, 'unknown'))),
/** @type {any} */ (updateBuildInfo()),
]);
const debugInfo = {
os: {
platform: os.platform(),
release: os.release(),
arch: os.arch(),
homedir: os.homedir(),
username: os.userInfo().username,
},
node: {
version: process.version,
arch: process.arch,
cwd: process.cwd(),
argv: process.argv,
env: process.env,
npm: {
location: npmLocation,
version: npmVersion,
},
},
appium: {
location: rootDir,
homedir: appiumHome,
build: getBuildInfo(),
drivers: driverConfig.installedExtensions,
plugins: pluginConfig.installedExtensions,
},
};
console.log(JSON.stringify(debugInfo, null, 2));
}
/**
* @param {boolean} [useGithubApiFallback]
* @returns {Promise<string?>}
*/
export async function getGitRev(useGithubApiFallback = false) {
const fullGitPath = await getFullGitPath();
if (fullGitPath) {
try {
const {stdout} = await exec(fullGitPath, ['rev-parse', 'HEAD'], {
cwd: __dirname,
});
return stdout.trim();
} catch {}
}
if (!useGithubApiFallback) {
return null;
}
// If the package folder is not a valid git repository
// then fetch the corresponding tag info from GitHub
try {
return (
await axios.get(`${GITHUB_API}/git/refs/tags/appium@${APPIUM_VER}`, {
headers: {
'User-Agent': `Appium ${APPIUM_VER}`,
},
})
).data?.object?.sha;
} catch {}
return null;
}
/**
* @param {string} commitSha
* @param {boolean} [useGithubApiFallback]
* @returns {Promise<string?>}
*/
async function getGitTimestamp(commitSha, useGithubApiFallback = false) {
const fullGitPath = await getFullGitPath();
if (fullGitPath) {
try {
const {stdout} = await exec(fullGitPath, ['show', '-s', '--format=%ci', commitSha], {
cwd: __dirname,
});
return stdout.trim();
} catch {}
}
if (!useGithubApiFallback) {
return null;
}
try {
return (
await axios.get(`${GITHUB_API}/git/tags/${commitSha}`, {
headers: {
'User-Agent': `Appium ${APPIUM_VER}`,
},
})
).data?.tagger?.date;
} catch {}
return null;
}
/**
* Mutable object containing Appium build information. By default it
* only contains the Appium version, but is updated with the build timestamp
* and git commit hash asynchronously as soon as `updateBuildInfo` is called
* and succeeds.
* @returns {import('appium/types').BuildInfo}
*/
export function getBuildInfo() {
return BUILD_INFO;
}
/**
* @returns {void}
* @throws {Error} If Node version is outside of the supported range
*/
export function checkNodeOk() {
const version = getNodeVersion();
if (!semver.satisfies(version, MIN_NODE_VERSION)) {
throw new Error(
`Node version must be at least ${MIN_NODE_VERSION}; current is ${version.version}`
);
}
}
export async function showBuildInfo() {
await updateBuildInfo(true);
console.log(JSON.stringify(getBuildInfo()));
}
/**
* Returns k/v pairs of server arguments which are _not_ the defaults.
* @param {Args} parsedArgs
* @returns {Args}
*/
export function getNonDefaultServerArgs(parsedArgs) {
/**
* Flattens parsed args into a single level object for comparison with
* flattened defaults across server args and extension args.
* @param {Args} args
* @returns {Record<string, { value: any, argSpec: ArgSpec }>}
*/
const flatten = (args) => {
const argSpecs = getAllArgSpecs();
const flattened = _.reduce(
[...argSpecs.values()],
(acc, argSpec) => {
if (_.has(args, argSpec.dest)) {
acc[argSpec.dest] = {value: _.get(args, argSpec.dest), argSpec};
}
return acc;
},
/** @type {Record<string, { value: any, argSpec: ArgSpec }>} */ ({})
);
return flattened;
};
const args = flatten(parsedArgs);
// hopefully these function names are descriptive enough
const typesDiffer = /** @param {string} dest */ (dest) =>
typeof args[dest].value !== typeof defaultsFromSchema[dest];
const defaultValueIsArray = /** @param {string} dest */ (dest) =>
_.isArray(defaultsFromSchema[dest]);
const argsValueIsArray = /** @param {string} dest */ (dest) => _.isArray(args[dest].value);
const arraysDiffer = /** @param {string} dest */ (dest) =>
_.gt(_.size(_.difference(args[dest].value, defaultsFromSchema[dest])), 0);
const valuesDiffer = /** @param {string} dest */ (dest) =>
args[dest].value !== defaultsFromSchema[dest];
const defaultIsDefined = /** @param {string} dest */ (dest) =>
!_.isUndefined(defaultsFromSchema[dest]);
// note that `_.overEvery` is like an "AND", and `_.overSome` is like an "OR"
const argValueNotArrayOrArraysDiffer = _.overSome([_.negate(argsValueIsArray), arraysDiffer]);
const defaultValueNotArrayAndValuesDiffer = _.overEvery([
_.negate(defaultValueIsArray),
valuesDiffer,
]);
/**
* 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
* @type {(dest: string) => boolean}
*/
const isNotDefault = _.overEvery([
defaultIsDefined,
_.overSome([
typesDiffer,
_.overEvery([defaultValueIsArray, argValueNotArrayOrArraysDiffer]),
defaultValueNotArrayAndValuesDiffer,
]),
]);
const defaultsFromSchema = getDefaultsForSchema(true);
return _.reduce(
_.pickBy(args, (__, key) => isNotDefault(key)),
// explodes the flattened object back into nested one
(acc, {value, argSpec}) => _.set(acc, argSpec.dest, value),
/** @type {Args} */ ({})
);
}
/**
* Compacts an object for {@link showConfig}:
* 1. Removes `subcommand` key/value
* 2. Removes `undefined` values
* 3. Removes empty objects (but not `false` values)
* Does not operate recursively.
*/
const compactConfig = _.partial(
_.omitBy,
_,
(value, key) =>
key === 'subcommand' || _.isUndefined(value) || (_.isObject(value) && _.isEmpty(value))
);
/**
* Shows a breakdown of the current config after CLI params, config file loaded & defaults applied.
*
* The actual shape of `preConfigParsedArgs` and `defaults` does not matter for the purposes of this function,
* but it's intended to be called with values of type {@link ParsedArgs} and `DefaultValues<true>`, respectively.
*
* @param {Partial<ParsedArgs>} nonDefaultPreConfigParsedArgs - Parsed CLI args (or param to `init()`) before config & defaults applied
* @param {import('./config-file').ReadConfigFileResult} configResult - Result of attempting to load a config file. _Must_ be normalized
* @param {Partial<ParsedArgs>} defaults - Configuration defaults from schemas
* @param {ParsedArgs} parsedArgs - Entire parsed args object
*/
export function showConfig(nonDefaultPreConfigParsedArgs, configResult, defaults, parsedArgs) {
console.log('Appium Configuration\n');
console.log('from defaults:\n');
console.dir(compactConfig(defaults));
if (configResult.config) {
console.log(`\nfrom config file at ${configResult.filepath}:\n`);
console.dir(compactConfig(configResult.config));
} else {
console.log(`\n(no configuration file loaded)`);
}
const compactedNonDefaultPreConfigArgs = compactConfig(nonDefaultPreConfigParsedArgs);
if (_.isEmpty(compactedNonDefaultPreConfigArgs)) {
console.log(`\n(no CLI parameters provided)`);
} else {
console.log('\nvia CLI or function call:\n');
console.dir(compactedNonDefaultPreConfigArgs);
}
console.log('\nfinal configuration:\n');
console.dir(compactConfig(parsedArgs));
}
/**
* @param {string} root
* @param {boolean} [requireWriteable=true]
* @param {string} [displayName='folder path']
* @throws {Error}
*/
export async function requireDir(root, requireWriteable = true, displayName = 'folder path') {
let stat;
try {
stat = await fs.stat(root);
} catch (e) {
if (e.code === 'ENOENT') {
try {
await fs.mkdir(root, {recursive: true});
return;
} catch {}
}
throw new Error(`The ${displayName} '${root}' must exist and be a valid directory`);
}
if (stat && !stat.isDirectory()) {
throw new Error(`The ${displayName} '${root}' must be a valid directory`);
}
if (requireWriteable) {
try {
await fs.access(root, fs.constants.W_OK);
} catch {
throw new Error(
`The ${displayName} '${root}' must be ` +
`writeable for the current user account '${os.userInfo().username}'`
);
}
}
}
/**
* Calculates the result of the given function and return its value
* or the default one if there was an exception.
*
* @template T
* @param {() => Promise<T>} f
* @param {T} defaultValue
* @returns {Promise<T>}
*/
async function getSafeResult(f, defaultValue) {
try {
return await f();
} catch {
return defaultValue;
}
}
/**
* @typedef {import('appium/types').ParsedArgs} ParsedArgs
* @typedef {import('appium/types').Args} Args
* @typedef {import('./schema/arg-spec').ArgSpec} ArgSpec
*/