UNPKG

nightwatch

Version:

Easy to use Node.js based end-to-end testing solution for web applications using the W3C WebDriver API.

708 lines (569 loc) 20.7 kB
const path = require('path'); const lodashCloneDeep = require('lodash/cloneDeep'); const ArgvSetup = require('./argv-setup.js'); const Settings = require('../../settings/settings.js'); const Globals = require('../../testsuite/globals.js'); const Factory = require('../../transport/factory.js'); const Concurrency = require('../concurrency'); const Utils = require('../../utils'); const Runner = require('../runner.js'); const ProcessListener = require('../process-listener.js'); const analyticsCollector = require('../../utils/analytics.js'); const {RealIosDeviceIdError, iosRealDeviceUDID, isRealIos, isMobile, killSimulator, isAndroid} = require('../../utils/mobile.js'); const {Logger, singleSourceFile, isSafari, isLocalhost} = Utils; const NightwatchEvent = require('../eventHub.js'); const {NightwatchEventHub, DEFAULT_RUNNER_EVENTS} = NightwatchEvent; const {GlobalHook} = DEFAULT_RUNNER_EVENTS; class CliRunner { static get CONFIG_FILE_JS() { return './nightwatch.conf.js'; } static get CONFIG_FILE_CJS() { return './nightwatch.conf.cjs'; } static get CONFIG_FILE_TS() { return './nightwatch.conf.ts'; } static createDefaultConfig(destFileName) { // eslint-disable-next-line no-console console.log(Logger.colors.cyan('No config file found in the current working directory, creating nightwatch.conf.js in the current folder...')); const templateFile = path.join(__dirname, 'nightwatch.conf.ejs'); const os = require('os'); const fs = require('fs'); const ejs = require('ejs'); const tplData = fs.readFileSync(templateFile).toString(); let launch_url = 'https://nightwatchjs.org'; const availablePlugins = []; const autoLoadPlugins = [{ 'vite-plugin-nightwatch': { launch_url: 'http://localhost:3000' } }, { '@nightwatch/storybook': { launch_url: 'http://localhost:6006' } }, { '@nightwatch/react': {} }]; autoLoadPlugins.forEach(plugin => { try { const pluginName = Object.keys(plugin)[0]; const pluginPath = Utils.getPluginPath(pluginName); availablePlugins.push(pluginName); if (plugin[pluginName].launch_url) { launch_url = plugin[pluginName].launch_url; } } catch (err) { // plugin is not installed } }); let rendered = ejs.render(tplData, { plugins: (availablePlugins.length > 0) ? JSON.stringify(availablePlugins) : '[]', launch_url, isMacOS: os.platform() === 'darwin' }); rendered = Utils.stripControlChars(rendered); try { fs.writeFileSync(destFileName, rendered, {encoding: 'utf-8'}); return true; } catch (err) { Logger.error(`Failed to save nightwatch.conf.js config file to ${destFileName}. You need to manually create either a nightwatch.json or nightwatch.conf.js configuration file.`); Logger.error(err); return false; } } constructor(argv = {}) { if (argv.source && !argv._source) { argv._source = argv.source; delete argv.source; } if (argv._source && Utils.isString(argv._source)) { argv._source = [argv._source]; } if (argv.firefox) { argv.e = argv.env = 'firefox'; } else if (argv.chrome) { argv.e = argv.env = 'chrome'; } else if (argv.safari) { argv.e = argv.env = 'safari'; } else if (argv.edge) { argv.e = argv.env = 'edge'; } this.argv = argv; this.testRunner = null; this.globals = null; this.testEnv = null; this.testEnvArray = []; if (!argv.disable_process_listener) { this.processListener = new ProcessListener(); } } initTestSettings(userSettings = {}, baseSettings = null, argv = null, testEnv = '', asyncLoading) { this.test_settings = Settings.parse(userSettings, baseSettings, argv, testEnv); this.setMobileOptions(argv); this.setLoggingOptions(); this.setupGlobalHooks(); const result = this.readExternalHooks(asyncLoading); if (result instanceof Promise) { return result.then(() => { this.globalsSetup(argv); }); } this.globalsSetup(argv); return this; } globalsSetup(argv) { this.globals.init(); this.setTimeoutOptions(argv); } setTimeoutOptions(argv) { if (argv.timeout) { const timeout = parseInt(argv.timeout, 10); if (!isNaN(timeout)) { this.test_settings.globals.waitForConditionTimeout = timeout; this.test_settings.globals.retryAssertionTimeout = timeout; } } } setMobileOptions(argv) { const {desiredCapabilities, selenium = {}} = this.test_settings; if (isRealIos(desiredCapabilities) && !selenium.use_appium) { if (argv.deviceId) { this.test_settings.desiredCapabilities['safari:deviceUDID'] = iosRealDeviceUDID(argv.deviceId); } else if (!desiredCapabilities['safari:deviceUDID']) { throw new RealIosDeviceIdError(); } } if (isAndroid(desiredCapabilities) && argv.deviceId && !selenium.use_appium) { if (desiredCapabilities['goog:chromeOptions']) { this.test_settings.desiredCapabilities['goog:chromeOptions'].androidDeviceSerial = argv.deviceId; } else if (desiredCapabilities['moz:firefoxOptions']) { this.test_settings.desiredCapabilities['moz:firefoxOptions'].androidDeviceSerial = argv.deviceId; } } } setupGlobalHooks() { this.globals = new Globals(this.test_settings, this.argv, this.testEnv); } readExternalHooks(asyncLoading = true) { return this.globals.readExternal(asyncLoading); } /** * backwards compatibility * @readonly * @deprecated * @return {*} */ get settings() { return this.baseSettings; } setCurrentTestEnv() { this.testEnv = Utils.isString(this.argv.env) ? this.argv.env : Settings.DEFAULT_ENV; this.testEnvArray = this.testEnv.split(','); if (!this.baseSettings) { return this; } this.availableTestEnvs = Object.keys(this.baseSettings.test_settings).filter(key => { return Utils.isObject(this.baseSettings.test_settings[key]); }); this.validateTestEnvironments(); return this; } setLoggingOptions() { Logger.setOptions(this.test_settings); return this; } /** * @param {object} [settings] * @return {CliRunner} */ async setupAsync(settings) { this.baseSettings = await this.loadConfig(); await this.commonSetup(settings); return this; } /** * Backwords compatibility for runner * @param {object} [settings] * @return {CliRunner} */ setup(settings) { this.baseSettings = this.loadConfig(); this.commonSetup(settings, false); return this; } commonSetup(settings, asyncLoading = true) { this.validateConfig(); this.setCurrentTestEnv(); const result = this.parseTestSettings(settings, asyncLoading); if (asyncLoading) { return result.then(() => this.runnerSetup()); } this.runnerSetup(); return this; } runnerSetup() { this.createTestRunner(); this.setupConcurrency(); this.loadTypescriptTranspiler(); analyticsCollector.updateSettings(this.test_settings); analyticsCollector.updateLogger(Logger); analyticsCollector.collectEvent('nw_test_run', { arg_parallel: this.argv.parallel, browser_name: this.test_settings.desiredCapabilities.browserName, test_workers_enabled: this.test_settings.testWorkersEnabled, use_xpath: this.test_settings.use_xpath, is_bstack: this.test_settings.desiredCapabilities['bstack:options'] !== undefined, test_runner: this.test_settings.test_runner ? this.test_settings.test_runner.type : null }); this.setupEventHub(); } isRegisterEventHandlersCallbackExistsInGlobal() { const {globals} = this.test_settings; const {plugins = []} = this.globals; return Utils.isFunction(globals.registerEventHandlers) || plugins.some(plugin => plugin.globals && Utils.isFunction(plugin.globals.registerEventHandlers)); } setupEventHub() { if (this.isRegisterEventHandlersCallbackExistsInGlobal() && !NightwatchEventHub.isAvailable) { NightwatchEventHub.runner = this.testRunner.type; NightwatchEventHub.isAvailable = true; const {globals, output_folder} = this.test_settings; NightwatchEventHub.output_folder = output_folder; const {plugins} = this.globals; if (Utils.isFunction(globals.registerEventHandlers)) { globals.registerEventHandlers(NightwatchEventHub); } if (plugins.length > 0) { plugins.forEach((plugin) => { if (plugin.globals && Utils.isFunction(plugin.globals.registerEventHandlers)) { plugin.globals.registerEventHandlers(NightwatchEventHub); } }); } } } loadTypescriptTranspiler() { const projectTsFilePath = Utils.findTSConfigFile(this.test_settings.tsconfig_path); if (projectTsFilePath !== '' && !this.test_settings.disable_typescript) { Utils.loadTSNode(projectTsFilePath); } } isConfigDefault(configFile, localJsValue = CliRunner.CONFIG_FILE_JS) { return ArgvSetup.isDefault('config', configFile) || path.resolve(configFile) === localJsValue; } getLocalConfigFileName() { let packageInfo; try { packageInfo = require(path.resolve('package.json')); } catch (err) { packageInfo = null; } packageInfo = packageInfo || {}; const usingESM = packageInfo.type === 'module'; const usingTS = Utils.fileExistsSync(CliRunner.CONFIG_FILE_TS); if (usingTS) { return path.resolve(CliRunner.CONFIG_FILE_TS); } if (usingESM) { return path.resolve(CliRunner.CONFIG_FILE_CJS); } return path.resolve(CliRunner.CONFIG_FILE_JS); } loadConfig() { if (!this.argv.config) { return null; } const localJsOrTsValue = this.getLocalConfigFileName(); // use default nightwatch.json file if we haven't received another value if (this.isConfigDefault(this.argv.config, localJsOrTsValue)) { let newConfigCreated = false; const defaultValue = ArgvSetup.getDefault('config'); const hasJsOrTsConfig = Utils.fileExistsSync(localJsOrTsValue); const hasJsonConfig = Utils.fileExistsSync(defaultValue); if (!hasJsOrTsConfig && !hasJsonConfig) { newConfigCreated = CliRunner.createDefaultConfig(localJsOrTsValue); } if (hasJsOrTsConfig || newConfigCreated) { this.argv.config = localJsOrTsValue; } else if (hasJsonConfig) { this.argv.config = path.join(path.resolve('./'), this.argv.config); } } else { this.argv.config = path.resolve(this.argv.config); } return require(this.argv.config); } validateConfig() { // checking if the env passed is valid if (this.baseSettings && !this.baseSettings.test_settings) { this.baseSettings.test_settings = { default: {} }; } return this; } /** * Validates and parses the test settings * @param {object} [settings] * @returns {CliRunner|Promise} */ parseTestSettings(settings = {}, asyncLoading = true) { this.userSettings = lodashCloneDeep(settings); const result = this.initTestSettings(settings, this.baseSettings, this.argv, this.testEnv, asyncLoading); if (result instanceof Promise) { return result; } return this; } runGlobalHook(key, args = [], isParallelHook = false) { let promise; const {globals} = this.test_settings; if (isParallelHook && Concurrency.isWorker() || !isParallelHook && !Concurrency.isWorker()) { const start_time = new Date(); if (Utils.isFunction(globals[key])) { NightwatchEventHub.emit(GlobalHook[key].started, { start_time: start_time }); } promise = this.globals.runGlobalHook(key, args); return promise.finally(() => { if (Utils.isFunction(globals[key])) { NightwatchEventHub.emit(GlobalHook[key].finished, { start_time: start_time, end_time: new Date() }); } }); } return Promise.resolve(); } validateTestEnvironments() { for (let i = 0; i < this.testEnvArray.length; i++) { if (this.testEnvArray[i] === 'default') { continue; } if (!(this.testEnvArray[i] in this.baseSettings.test_settings)) { const error = new Error(`Invalid testing environment specified: ${this.testEnvArray[i]}. \n\n ${Logger.colors.light_cyan('Available environments are:')}\n ${Logger.inspectObject(this.availableTestEnvs)}`); error.showTrace = false; throw error; } } return this; } /////////////////////////////////////////////////////////////////////////////////////////////////////////// // Concurrency related /////////////////////////////////////////////////////////////////////////////////////////////////////////// get testWorkersMode() { return this.isTestWorkersEnabled() && !this.testRunner.isTestWorker(); } usingServer(test_settings = {}) { // TODO: selenium_host and seleniumHost are for backwards compatability. // remove these in future versions. return Utils.isObject(test_settings.selenium) || test_settings.selenium_host || test_settings.seleniumHost; } isTestWorkersEnabled() { if (this.testRunner.supportsParallelTestSuiteRun === false) { return false; } const testWorkers = this.test_settings.testWorkersEnabled && !singleSourceFile(this.argv); if (!testWorkers) { if (this.argv.debug) { Logger.info('Disabling parallelism while running in debug mode'); } return false; } for (const env of this.testEnvArray) { const {webdriver = {}} = this.testEnvSettings[env]; const desiredCapabilities = this.testEnvSettings[env].capabilities || this.testEnvSettings[env].desiredCapabilities; if (isMobile(desiredCapabilities) && !this.usingServer(this.testEnvSettings[env])) { if (Concurrency.isWorker()) { Logger.info('Disabling parallelism while running tests on mobile platform'); } return false; } if (isSafari(desiredCapabilities) && isLocalhost(webdriver)) { this.isSafariEnvPresent = true; if (Concurrency.isMasterProcess()) { // eslint-disable-next-line no-console console.warn('Running tests in parallel is not supported in Safari. Tests will run in serial mode.'); } return false; } } return true; } parallelMode() { return this.testEnvArray.length > 1 || this.testWorkersMode; } setupConcurrency() { if (this.testRunner?.type === Runner.MOCHA_RUNNER) { this.test_settings.use_child_process = true; } this.concurrency = new Concurrency(this.test_settings, this.argv, this.isTestWorkersEnabled()); return this; } isConcurrencyEnabled() { return this.testRunner.supportsConcurrency && this.parallelMode(); } /////////////////////////////////////////////////////////////////////////////////////////////////////////// // Test runner related /////////////////////////////////////////////////////////////////////////////////////////////////////////// executeTestRunner(modules) { return this.testRunner.run(modules); } createTestRunner() { this.testRunner = Runner.create(this.test_settings, this.argv, { globalHooks: this.globals ? this.globals.hooks : null, globalsInstance: this.globals }); if (this.processListener) { this.processListener.setTestRunner(this.testRunner); } return this; } get testEnvSettings() { if (this.__testEnvSettings) { return this.__testEnvSettings; } this.__testEnvSettings = {}; for (const env of this.testEnvArray) { this.__testEnvSettings[env] = Settings.parse(this.userSettings, this.baseSettings, this.argv, env); } return this.__testEnvSettings; } async getTestsFiles() { const modules = {}; for (const env of this.testEnvArray) { modules[env] = await Runner.readTestSource(this.testEnvSettings[env], this.argv); } return modules; } /** * * @param [done] * @return {*} */ runTests(done = null) { return this.runGlobalHook('before', [this.test_settings]) .then(_ => { return this.runGlobalHook('beforeChildProcess', [this.test_settings], true); }) .then(_ => { return this.getTestsFiles(); }) .then(modules => { if (!this.testRunner) { const error = new Error(`Test runner '${this.test_settings.test_runner.type}' is not known.`); error.showTrace = false; error.detailedErr = `\n Verify "test_runner" settings: \n ${Logger.inspectObject(this.test_settings.test_runner)}`; throw error; } let promise = Promise.resolve(); if (this.test_settings.selenium && this.test_settings.selenium.start_process) { promise = Factory.createSeleniumService(this); } const {real_mobile, avd} = this.test_settings.desiredCapabilities; if (!real_mobile && avd) { const AndroidServer = require('../androidEmulator.js'); this.androidServer = new AndroidServer(avd); promise = promise.then(() => this.androidServer.launchEmulator()); } return promise.then(() => { if (this.isConcurrencyEnabled()) { return this.testRunner.runConcurrent(this.testEnvArray, modules, this.isTestWorkersEnabled(), this.isSafariEnvPresent) .then(exitCode => { if (exitCode > 0) { this.processListener.setExitCode(exitCode); } }); } return this.executeTestRunner(modules[this.testEnv]); }); }) .catch(err => { if (err.detailedErr) { err.data = err.detailedErr; } if (!err.sessionCreate && !err.displayed) { Logger.error(err); if (err.data) { Logger.warn(' ' + err.data); } // eslint-disable-next-line no-console console.log(''); } err.displayed = true; return err; }) .then(errorOrFailed => { if (this.seleniumService) { // stop the Selenium Server if running const {service} = this.seleniumService; if (service && service.kill) { // Give the selenium server some time to close down its browser drivers return new Promise((resolve, reject) => { setTimeout(() => { service.kill() .catch(err => { Logger.error(err); }) .then(() => this.seleniumService.stop()) .then(() => resolve()); }, 100); }).then(() => { return errorOrFailed; }); } } return errorOrFailed; }) .then(errorOrFailed => { if (errorOrFailed instanceof Error || errorOrFailed === true) { try { this.processListener.setExitCode(5); } catch (e) { // eslint-disable-next-line no-console console.error(e); } } return this.runGlobalHook('afterChildProcess', [], true) .then(_ => { return this.runGlobalHook('after'); }) .then(result => { return errorOrFailed; }); }) .catch(err => { // eslint-disable-next-line no-console console.log(''); try { this.processListener.setExitCode(5); } catch (e) { // eslint-disable-next-line no-console console.error(e); } return err; }) .then(errorOrFailed => { if (typeof done == 'function' && !Concurrency.isWorker()) { if (errorOrFailed instanceof Error) { return done(errorOrFailed); } return done(); } if (errorOrFailed instanceof Error) { throw errorOrFailed; } if (this.androidServer && !this.androidServer.emulatorAlreadyRunning) { this.androidServer.killEmulator(); } if (this.test_settings.deviceUDID && !isRealIos(this.test_settings.desiredCapabilities)) { killSimulator(this.test_settings.deviceUDID); } analyticsCollector.__flush(); return errorOrFailed; }); } } module.exports = CliRunner;