appium
Version:
Automation for Apps.
534 lines (479 loc) • 17.7 kB
JavaScript
import {WebSocketServer} from 'ws';
import {init as logsinkInit} from './logsink'; // this import needs to come first since it sets up global npmlog
import logger from './logger'; // logger needs to remain second
import {
routeConfiguringFunction as makeRouter,
server as baseServer,
normalizeBasePath,
} from '@appium/base-driver';
import {util, env} from '@appium/support';
import {asyncify} from 'asyncbox';
import _ from 'lodash';
import {AppiumDriver} from './appium';
import {runExtensionCommand} from './cli/extension';
import { runSetupCommand } from './cli/setup-command';
import {getParser} from './cli/parser';
import {
APPIUM_VER,
checkNodeOk,
getGitRev,
getNonDefaultServerArgs,
showConfig,
showBuildInfo,
showDebugInfo,
requireDir,
} from './config';
import {readConfigFile} from './config-file';
import {loadExtensions, getActivePlugins, getActiveDrivers} from './extension';
import {SERVER_SUBCOMMAND, LONG_STACKTRACE_LIMIT} from './constants';
import registerNode from './grid-register';
import {getDefaultsForSchema, validate as validateSchema} from './schema/schema';
import {
inspect,
adjustNodePath,
isDriverCommandArgs,
isExtensionCommandArgs,
isPluginCommandArgs,
isServerCommandArgs,
fetchInterfaces,
V4_BROADCAST_IP,
isSetupCommandArgs,
isBroadcastIp,
} from './utils';
import net from 'node:net';
const {resolveAppiumHome} = env;
/*
* By default Node.js shows a warning
* if the actual amount of listeners exceeds the maximum amount,
* which equals to 10 by default. It is known that multiple drivers/plugins
* may assign custom listeners to the server process to handle, for example,
* the graceful shutdown scenario.
*/
const MAX_SERVER_PROCESS_LISTENERS = 100;
/**
*
* @param {ParsedArgs} args
* @param {boolean} [throwInsteadOfExit]
*/
async function preflightChecks(args, throwInsteadOfExit = false) {
try {
checkNodeOk();
if (args.longStacktrace) {
Error.stackTraceLimit = LONG_STACKTRACE_LIMIT;
}
if (args.showBuildInfo) {
await showBuildInfo();
process.exit(0);
}
validateSchema(args);
if (args.tmpDir) {
await requireDir(args.tmpDir, !args.noPermsCheck, 'tmpDir argument value');
}
} catch (err) {
logger.error(err.message.red);
if (throwInsteadOfExit) {
throw err;
}
process.exit(1);
}
}
/**
* @param {Args} args
*/
function logNonDefaultArgsWarning(args) {
logger.info('Non-default server args:');
inspect(args);
}
/**
* @param {Args['defaultCapabilities']} caps
*/
function logDefaultCapabilitiesWarning(caps) {
logger.info(
'Default capabilities, which will be added to each request ' +
'unless overridden by desired capabilities:',
);
inspect(caps);
}
/**
* @param {ParsedArgs} args
*/
async function logStartupInfo(args) {
let welcome = `Welcome to Appium v${APPIUM_VER}`;
let appiumRev = await getGitRev();
if (appiumRev) {
welcome += ` (REV ${appiumRev})`;
}
logger.info(welcome);
let showArgs = getNonDefaultServerArgs(args);
if (_.size(showArgs)) {
logNonDefaultArgsWarning(showArgs);
}
if (!_.isEmpty(args.defaultCapabilities)) {
logDefaultCapabilitiesWarning(args.defaultCapabilities);
}
// TODO: bring back loglevel reporting below once logger is flushed out
// logger.info('Console LogLevel: ' + logger.transports.console.level);
// if (logger.transports.file) {
// logger.info('File LogLevel: ' + logger.transports.file.level);
// }
}
/**
* Gets a list of `updateServer` functions from all extensions
* @param {DriverNameMap} driverClasses
* @param {PluginNameMap} pluginClasses
* @returns {import('@appium/types').UpdateServerCallback[]}
*/
function getServerUpdaters(driverClasses, pluginClasses) {
return _.compact(_.map([...driverClasses.keys(), ...pluginClasses.keys()], 'updateServer'));
}
/**
* Makes a big `MethodMap` from all the little `MethodMap`s in the extensions
* @param {DriverNameMap} driverClasses
* @param {PluginNameMap} pluginClasses
* @returns {import('@appium/types').MethodMap<import('@appium/types').Driver>}
*/
function getExtraMethodMap(driverClasses, pluginClasses) {
return [...driverClasses.keys(), ...pluginClasses.keys()].reduce(
(map, klass) => ({
...map,
...(klass.newMethodMap ?? {}),
}),
{},
);
}
/**
* @param {string?} [appiumHomeFromArgs] - Appium home value retrieved from progrmmatic server args
* @returns {string}
*/
function determineAppiumHomeSource(appiumHomeFromArgs) {
if (!_.isNil(appiumHomeFromArgs)) {
return 'appiumHome config value';
} else if (process.env.APPIUM_HOME) {
return 'APPIUM_HOME environment variable';
}
return 'autodetected Appium home path';
}
/**
* Initializes Appium, but does not start the server.
*
* Use this to get at the configuration schema.
*
* If `args` contains a non-empty `subcommand` which is not `server`, this function will return an empty object.
*
* @template {CliCommand} [Cmd=ServerCommand]
* @template {CliExtensionSubcommand|void} [SubCmd=void]
* @param {Args<Cmd, SubCmd>} [args] - Partial args (programmatic usage only)
* @returns {Promise<InitResult<Cmd>>}
* @example
* import {init, getSchema} from 'appium';
* const options = {}; // config object
* await init(options);
* const schema = getSchema(); // entire config schema including plugins and drivers
*/
async function init(args) {
const appiumHome = args?.appiumHome ?? (await resolveAppiumHome());
const appiumHomeSourceName = determineAppiumHomeSource(args?.appiumHome);
// We verify the writeability later based on requested server arguments
// Here we just need to make sure the path exists and is a folder
await requireDir(appiumHome, false, appiumHomeSourceName);
adjustNodePath();
const {driverConfig, pluginConfig} = await loadExtensions(appiumHome);
const parser = getParser();
let throwInsteadOfExit = false;
/** @type {Args<Cmd, SubCmd>} */
let preConfigArgs;
if (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
if (args.throwInsteadOfExit) {
throwInsteadOfExit = true;
// but remove it since it's not a real server arg per se
delete args.throwInsteadOfExit;
}
preConfigArgs = {...args, subcommand: args.subcommand ?? SERVER_SUBCOMMAND};
} else {
// otherwise parse from CLI
preConfigArgs = /** @type {Args<Cmd, SubCmd>} */ (parser.parseArgs());
}
const configResult = await readConfigFile(preConfigArgs.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 precedence is:
// 1. command line args
// 2. config file
// 3. defaults from config file.
if (isServerCommandArgs(preConfigArgs)) {
const defaults = getDefaultsForSchema(false);
/** @type {ParsedArgs} */
const serverArgs = _.defaultsDeep({}, preConfigArgs, configResult.config?.server, defaults);
if (preConfigArgs.showConfig) {
showConfig(getNonDefaultServerArgs(preConfigArgs), configResult, defaults, serverArgs);
return /** @type {InitResult<Cmd>} */ ({});
}
if (preConfigArgs.showDebugInfo) {
await showDebugInfo({
driverConfig,
pluginConfig,
appiumHome,
});
return /** @type {InitResult<Cmd>} */ ({});
}
await logsinkInit(serverArgs);
if (serverArgs.logFilters) {
const {issues, rules} = await logger.unwrap().loadSecureValuesPreprocessingRules(
serverArgs.logFilters,
);
const argToLog = _.truncate(JSON.stringify(serverArgs.logFilters), {
length: 150
});
if (!_.isEmpty(issues)) {
throw new Error(
`The log filtering rules config ${argToLog} has issues: ` +
JSON.stringify(issues, null, 2),
);
}
if (_.isEmpty(rules)) {
logger.warn(
`Found no log filtering rules in the ${argToLog} config. ` +
`Is that expected?`,
);
} else {
// Filtering aims to "hide" these values from the log,
// so it would be nice to not show them in the log as well.
logger.info(
`Loaded ${util.pluralize('filtering rule', rules.length, true)}`,
);
}
}
if (!serverArgs.noPermsCheck) {
await requireDir(appiumHome, true, appiumHomeSourceName);
}
const appiumDriver = new AppiumDriver(
/** @type {import('@appium/types').DriverOpts<import('./appium').AppiumDriverConstraints>} */ (
serverArgs
),
);
// set the config on the umbrella driver so it can match drivers to caps
appiumDriver.driverConfig = driverConfig;
await preflightChecks(serverArgs, throwInsteadOfExit);
return /** @type {InitResult<Cmd>} */ ({
appiumDriver,
parsedArgs: serverArgs,
driverConfig,
pluginConfig,
appiumHome,
});
} else if (isSetupCommandArgs(preConfigArgs)) {
await runSetupCommand(preConfigArgs, driverConfig, pluginConfig);
return /** @type {InitResult<Cmd>} */ ({});
} else {
await requireDir(appiumHome, true, appiumHomeSourceName);
if (isExtensionCommandArgs(preConfigArgs)) {
// if the user has requested the 'driver' CLI, don't run the normal server,
// but instead pass control to the driver CLI
if (isDriverCommandArgs(preConfigArgs)) {
await runExtensionCommand(preConfigArgs, driverConfig);
}
if (isPluginCommandArgs(preConfigArgs)) {
await runExtensionCommand(preConfigArgs, pluginConfig);
}
}
return /** @type {InitResult<Cmd>} */ ({});
}
}
/**
* Prints the actual server address and the list of URLs that
* could be used to connect to the current server.
* Properly replaces broadcast addresses in client URLs.
*
* @param {string} url The URL the server is listening on
*/
function logServerAddress(url) {
const urlObj = new URL(url);
logger.info(`Appium REST http interface listener started on ${url}`);
if (!isBroadcastIp(urlObj.hostname)) {
return;
}
const interfaces = fetchInterfaces(urlObj.hostname === V4_BROADCAST_IP ? 4 : 6);
const toLabel = (/** @type {import('node:os').NetworkInterfaceInfo} */ iface) => {
const href = urlObj.href.replace(urlObj.hostname, iface.address);
return iface.internal ? `${href} (only accessible from the same host)` : href;
};
logger.info(
`You can provide the following ${interfaces.length === 1 ? 'URL' : 'URLs'} ` +
`in your client code to connect to this server:\n` +
interfaces.map((iface) => `\t${toLabel(iface)}`).join('\n'),
);
}
/**
* Initializes Appium's config. Starts server if appropriate and resolves the
* server instance if so; otherwise resolves w/ `undefined`.
* @template {CliCommand} [Cmd=ServerCommand]
* @template {CliExtensionSubcommand|void} [SubCmd=void]
* @param {Args<Cmd, SubCmd>} [args] - Arguments from CLI or otherwise
* @returns {Promise<Cmd extends ServerCommand ? import('@appium/types').AppiumServer : void>}
*/
async function main(args) {
const initResult = await init(args);
if (_.isEmpty(initResult)) {
// if this branch is taken, we've run a different subcommand, so there's nothing
// left to do here.
return /** @type {Cmd extends ServerCommand ? import('@appium/types').AppiumServer : void} */ (
undefined
);
}
const {appiumDriver, pluginConfig, driverConfig, parsedArgs, appiumHome} =
/** @type {InitResult<ServerCommand>} */ (initResult);
const pluginClasses = await getActivePlugins(
pluginConfig, parsedArgs.pluginsImportChunkSize, parsedArgs.usePlugins
);
// set the active plugins on the umbrella driver so it can use them for commands
appiumDriver.pluginClasses = pluginClasses;
await logStartupInfo(parsedArgs);
// handle the insecure feature configuration since some features may apply globally
appiumDriver.configureGlobalFeatures();
const appiumHomeSourceName = determineAppiumHomeSource(args?.appiumHome);
logger.debug(`The ${appiumHomeSourceName}: ${appiumHome}`);
let routeConfiguringFunction = makeRouter(appiumDriver);
const driverClasses = await getActiveDrivers(
driverConfig, parsedArgs.driversImportChunkSize, parsedArgs.useDrivers
);
const serverUpdaters = getServerUpdaters(driverClasses, pluginClasses);
const extraMethodMap = getExtraMethodMap(driverClasses, pluginClasses);
/** @type {import('@appium/base-driver').ServerOpts} */
const serverOpts = {
routeConfiguringFunction,
port: parsedArgs.port,
hostname: parsedArgs.address,
allowCors: parsedArgs.allowCors,
basePath: parsedArgs.basePath,
serverUpdaters,
extraMethodMap,
cliArgs: parsedArgs,
};
for (const timeoutArgName of ['keepAliveTimeout', 'requestTimeout']) {
if (_.isInteger(parsedArgs[timeoutArgName])) {
serverOpts[timeoutArgName] = parsedArgs[timeoutArgName] * 1000;
}
}
let server;
const bidiServer = new WebSocketServer({noServer: true});
bidiServer.on('connection', appiumDriver.onBidiConnection.bind(appiumDriver));
bidiServer.on('error', appiumDriver.onBidiServerError.bind(appiumDriver));
try {
server = await baseServer(serverOpts);
server.addWebSocketHandler('/bidi', bidiServer);
server.addWebSocketHandler('/bidi/:sessionId', bidiServer);
} catch (err) {
logger.error(
`Could not configure Appium server. It's possible that a driver or plugin tried ` +
`to update the server and failed. Original error: ${err.message}`,
);
logger.debug(err.stack);
return process.exit(1);
}
if (parsedArgs.allowCors) {
logger.warn(
'You have enabled CORS requests from any host. Be careful not ' +
'to visit sites which could maliciously try to start Appium ' +
'sessions on your machine',
);
}
appiumDriver.server = server;
try {
// configure as node on grid, if necessary
// falsy values should not cause this to run
if (parsedArgs.nodeconfig) {
await registerNode(
parsedArgs.nodeconfig,
parsedArgs.address,
parsedArgs.port,
parsedArgs.basePath,
);
}
} catch (err) {
await server.close();
throw err;
}
process.setMaxListeners(MAX_SERVER_PROCESS_LISTENERS);
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async function onSignal() {
logger.info(`Received ${signal} - shutting down`);
try {
await appiumDriver.shutdown(`The process has received ${signal} signal`);
await server.close();
process.exit(0);
} catch (e) {
logger.warn(e);
process.exit(1);
}
});
}
const protocol = server.isSecure() ? 'https' : 'http';
const address = net.isIPv6(parsedArgs.address) ? `[${parsedArgs.address}]` : parsedArgs.address;
logServerAddress(
`${protocol}://${address}:${parsedArgs.port}${normalizeBasePath(parsedArgs.basePath)}`,
);
driverConfig.print();
pluginConfig.print([...pluginClasses.values()]);
return /** @type {Cmd extends ServerCommand ? import('@appium/types').AppiumServer : void} */ (
server
);
}
// NOTE: this is here for backwards compat for any scripts referencing `main.js` directly
// (more specifically, `build/lib/main.js`)
// the executable is now `../index.js`, so that module will typically be `require.main`.
if (require.main === module) {
asyncify(main);
}
// everything below here is intended to be a public API.
export {readConfigFile} from './config-file';
export {finalizeSchema, getSchema, validate} from './schema/schema';
export {main, init, resolveAppiumHome};
/**
* @typedef {import('@appium/types').DriverType} DriverType
* @typedef {import('@appium/types').PluginType} PluginType
* @typedef {import('@appium/types').DriverClass} DriverClass
* @typedef {import('@appium/types').PluginClass} PluginClass
* @typedef {import('appium/types').CliCommand} CliCommand
* @typedef {import('appium/types').CliExtensionSubcommand} CliExtensionSubcommand
* @typedef {import('appium/types').CliExtensionCommand} CliExtensionCommand
* @typedef {import('appium/types').CliCommandServer} ServerCommand
* @typedef {import('appium/types').CliCommandDriver} DriverCommand
* @typedef {import('appium/types').CliCommandPlugin} PluginCommand
* @typedef {import('appium/types').CliCommandSetup} SetupCommand
* @typedef {import('./extension').DriverNameMap} DriverNameMap
* @typedef {import('./extension').PluginNameMap} PluginNameMap
*/
/**
* Literally an empty object
* @typedef { {} } ExtCommandInitResult
*/
/**
* @typedef ServerInitData
* @property {import('./appium').AppiumDriver} appiumDriver - The Appium driver
* @property {import('appium/types').ParsedArgs} parsedArgs - The parsed arguments
* @property {string} appiumHome - The full path to the Appium home folder
*/
/**
* @template {CliCommand} Cmd
* @typedef {Cmd extends ServerCommand ? ServerInitData & import('./extension').ExtensionConfigs : ExtCommandInitResult} InitResult
*/
/**
* @template {CliCommand} [Cmd=ServerCommand]
* @template {CliExtensionSubcommand|void} [SubCmd=void]
* @typedef {import('appium/types').Args<Cmd, SubCmd>} Args
*/
/**
* @template {CliCommand} [Cmd=ServerCommand]
* @template {CliExtensionSubcommand|void} [SubCmd=void]
* @typedef {import('appium/types').ParsedArgs<Cmd, SubCmd>} ParsedArgs
*/