lib/appium/AppiumRunner.js (358 lines of code) (raw):

#!/usr/bin/env node /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ const fs = require('fs'); const path = require('path'); const wd = require('wd'); const wdHelper = require('./helpers/wdHelper'); const screenshotHelper = require('./helpers/screenshotHelper'); const appPatcher = require('./helpers/appPatcher.js'); const child_process = require('child_process'); const expectTelnet = require('expect-telnet'); const shell = require('shelljs'); const Jasmine = require('jasmine'); const unorm = require('unorm'); const Q = require('q'); const portChecker = require('tcp-port-used'); const { ParamedicReporter, getReporters } = require('../Reporters'); const { logger, exec, execPromise, utilities } = require('../utils'); const SMALL_BUFFER_SIZE = 1024 * 1024; const BIG_BUFFER_SIZE = 50 * 1024 * 1024; const APPIUM_SERVER_PATH = getAppiumServerPath(); class AppiumRunner { constructor (options) { this.options = options; this.prepareOptions(); this.createScreenshotDir(); this.findTests(); this.setGlobals(); } createScreenshotDir () { utilities.mkdirSync(this.options.screenshotPath); } prepareOptions () { if (!Object.prototype.hasOwnProperty.call(this.options, 'device')) { this.options.device = false; } if (this.options.platform === utilities.IOS && this.options.appiumDeviceName) { this.options.appiumDeviceName = this.options.appiumDeviceName.replace(/-/g, ' '); } } cleanUp (callback) { killProcess(this.appium, () => { killProcess(this.iosProxy, () => { callback(); }); }); } startTests () { const jasmine = new Jasmine(); const d = Q.defer(); const exitGracefully = (e) => { if (this.exiting) return; if (e) { logger.normal('paramedic-appium: ' + e); } logger.normal('paramedic-appium: Uncaught exception! Killing Appium server and exiting in 2 seconds...'); this.exiting = true; this.cleanUp(() => { setTimeout(() => { d.reject(e.stack); }, 2000); }); }; process.on('uncaughtException', (err) => { exitGracefully(err); }); logger.normal('paramedic-appium: Running tests from:'); this.options.testPaths.forEach((testPath) => { logger.normal('paramedic-appium: ' + testPath); }); jasmine.loadConfig({ spec_dir: '', spec_files: this.options.testPaths }); // don't use default reporter, it exits the process before // we would get the chance to kill appium server // jasmine.configureDefaultReporter({ showColors: false }); const outputDir = this.options.output || process.cwd(); const reporters = getReporters(outputDir); const paramedicReporter = new ParamedicReporter((passed) => { this.passed = passed; this.cleanUp(d.resolve); }); reporters.forEach((reporter) => { jasmine.addReporter(reporter); }); jasmine.addReporter(paramedicReporter); try { // Launch the tests! jasmine.execute(); } catch (e) { exitGracefully(e); } return d.promise; } startIosProxy () { let iosProxyCommand; this.iosProxy = { alive: false, process: null }; if (this.options.platform === utilities.IOS && this.options.device && this.options.udid) { iosProxyCommand = 'ios_webkit_debug_proxy -c ' + this.options.udid + ':27753'; logger.normal('paramedic-appium: Running:'); logger.normal('paramedic-appium: ' + iosProxyCommand); this.iosProxy.alive = true; console.log('$ ' + iosProxyCommand); this.iosProxy.process = child_process.exec(iosProxyCommand, { maxBuffer: BIG_BUFFER_SIZE }, () => { this.iosProxy.alive = false; logger.normal('paramedic-appium: iOS proxy process exited.'); }); } } startAppiumServer () { const d = Q.defer(); let additionalArgs = ''; this.appium = { alive: false, process: null }; // compose a command to run the Appium server switch (this.options.platform) { case utilities.ANDROID: additionalArgs += ' --allow-insecure chromedriver_autodownload'; break; case utilities.IOS: if (this.options.udid) { additionalArgs += ' --udid ' + this.options.udid; } break; default: throw new Error('Unsupported platform: ' + this.options.platform); } if (this.options.logFile) { additionalArgs += ' --log ' + this.options.logFile; } const appiumServerCommand = 'node ' + APPIUM_SERVER_PATH + additionalArgs; // run the Appium server logger.normal('paramedic-appium: Running:'); logger.normal('paramedic-appium: ' + appiumServerCommand); this.appium.alive = true; console.log('$ ' + appiumServerCommand); this.appium.process = child_process.exec(appiumServerCommand, { maxBuffer: BIG_BUFFER_SIZE }, (error) => { logger.normal('paramedic-appium: Appium process exited.'); if (this.appium.alive && error) { logger.normal('paramedic-appium: Error running appium server: ' + error); if (isFailFastError(error)) { this.cleanUp(d.reject); } else { logger.normal('paramedic-appium: Another instance already running? Will try to run tests on it.'); d.resolve(); } } this.appium.alive = false; }); // Wait for the Appium server to start up this.appium.process.stdout.on('data', (data) => { if (data.indexOf('Appium REST http interface listener started') > -1) { d.resolve(); } }); return d.promise; } findTests () { if (!this.options.pluginRepos) { this.options.pluginRepos = getPluginDirs(this.options.appPath); } // looking for the tests this.options.testPaths = []; const searchPaths = []; this.options.pluginRepos.forEach((pluginRepo) => { searchPaths.push(path.join(pluginRepo, 'appium-tests', this.options.platform)); searchPaths.push(path.join(pluginRepo, 'appium-tests', 'common')); }); searchPaths.forEach((searchPath) => { logger.normal('paramedic-appium: Looking for tests in: ' + searchPath); if (fs.existsSync(searchPath)) { logger.normal('paramedic-appium: Found tests in: ' + searchPath); if (path.isAbsolute(searchPath)) { searchPath = path.relative(process.cwd(), searchPath); } this.options.testPaths.push(path.join(searchPath, '*.spec.js')); } }); } setGlobals () { // setting up the global variables so the tests could use them global.WD = wd; global.WD_HELPER = wdHelper; global.SCREENSHOT_HELPER = screenshotHelper; global.ET = expectTelnet; global.SHELL = shell; global.DEVICE = this.options.device; global.DEVICE_NAME = this.options.appiumDeviceName; global.PLATFORM = this.options.platform; global.PLATFORM_VERSION = this.options.appiumPlatformVersion; global.SCREENSHOT_PATH = this.options.screenshotPath; global.UNORM = unorm; global.UDID = this.options.udid; global.VERBOSE = this.options.verbose; global.USE_SAUCE = this.options.sauce; global.SAUCE_USER = this.options.sauceUser; global.SAUCE_KEY = this.options.sauceKey; global.SAUCE_CAPS = this.options.sauceCaps; global.VERBOSE = this.options.verbose; global.SAUCE_SERVER_HOST = utilities.SAUCE_HOST; global.SAUCE_SERVER_PORT = utilities.SAUCE_PORT; } prepareApp () { const d = Q.defer(); const fullAppPath = getFullAppPath(this.options.appPath); const deviceString = this.options.device ? ' --device' : ''; const buildCommand = this.options.cli + ' build ' + this.options.platform + deviceString + utilities.PARAMEDIC_COMMON_CLI_ARGS; // remove medic.json and (re)build shell.rm(path.join(fullAppPath, 'www', 'medic.json')); fs.stat(fullAppPath, (error, stats) => { // check if the app exists if (error || !stats.isDirectory()) { d.reject('The app directory doesn\'t exist: ' + fullAppPath); } // set properties/CSP rules if (this.options.platform === utilities.IOS) { appPatcher.setPreference(fullAppPath, 'CameraUsesGeolocation', 'true'); } else if (this.options.platform === utilities.ANDROID) { appPatcher.setPreference(fullAppPath, 'loadUrlTimeoutValue', 60000); } appPatcher.addCspSource(fullAppPath, 'connect-src', 'http://*'); appPatcher.permitAccess(fullAppPath, '*'); // add cordova-save-image-gallery plugin from npm to enable // Appium tests for camera plugin to save test image to the gallery runCommand(this.options.cli + ' plugin add cordova-save-image-gallery' + utilities.PARAMEDIC_COMMON_CLI_ARGS, fullAppPath); // rebuild the app logger.normal('paramedic-appium: Building the app...'); console.log('$ ' + buildCommand); child_process.exec(buildCommand, { cwd: fullAppPath, maxBuffer: SMALL_BUFFER_SIZE }, (error, stdout, stderr) => { if (error || stdout.indexOf('BUILD FAILED') >= 0 || stderr.indexOf('BUILD FAILED') >= 0) { d.reject('Couldn\'t build the app: ' + error); } else { global.PACKAGE_PATH = getPackagePath(this.options); d.resolve(); } }); }); return d.promise; } runTests (useSauce) { return Q().then(() => { if (!useSauce) { this.startIosProxy(); // check if Appium is already running return portChecker.check(4723).then((isInUse) => { if (!isInUse) { return installAppiumServer() .then(this.startAppiumServer.bind(this)); } logger.info('paramedic-appium: Appium port is taken, looks like it is already running. Jumping straight to running tests.'); }); } }) .then(this.startTests.bind(this)) .then(() => this.passed); } } function getAppiumServerPath () { return path.resolve(__dirname, '..', '..', 'node_modules', 'appium', 'build', 'lib', 'main.js'); } function getFullAppPath (appPath) { let fullPath = appPath; if (!path.isAbsolute(appPath)) { fullPath = path.join(__dirname, '..', '..', appPath); } return fullPath; } function getPackagePath (options) { if (options.sauce) return options.sauceAppPath; const fullAppPath = getFullAppPath(options.appPath); switch (options.platform) { case utilities.ANDROID: { let packagePath = null; const maybePackagePaths = [ path.join(fullAppPath, 'platforms', 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'), path.join(fullAppPath, 'platforms', 'android', 'app', 'build', 'outputs', 'apk', 'android-debug.apk'), path.join(fullAppPath, 'platforms', 'android', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk') ]; maybePackagePaths.forEach((p) => { if (fs.existsSync(p)) { packagePath = p; } }); if (packagePath != null) { return packagePath; } throw new Error('Could not find apk'); } case utilities.IOS: { const searchDir = options.device ? path.join(fullAppPath, 'platforms', 'ios', 'build', 'device') : path.join(fullAppPath, 'platforms', 'ios', 'build', 'emulator'); const mask = options.device ? '.ipa$' : '.app$'; const files = fs.readdirSync(searchDir) .filter(file => file.match(new RegExp(mask))); logger.normal(`paramedic-appium: Looking for the app package in "${searchDir}" with the filter of "${mask}"`); if (files && files.length > 0) { logger.normal('paramedic-appium: Found the app package: ' + files[0]); return path.resolve(searchDir, files[0]); } throw new Error('Could not find the app package'); } } } function getPluginDirs (appPath) { return shell.ls(path.join(appPath, 'plugins', 'cordova-plugin-*')); } function runCommand (command, appPath) { if (appPath) { shell.pushd(appPath); } exec(command); if (appPath) { shell.popd(); } } function isFailFastError (error) { if (error && error.message) { return error.message.indexOf('Could not find a connected') > -1 || error.message.indexOf('Bad app') > -1; } return false; } function killProcess (procObj, callback) { if (procObj && procObj.alive) { procObj.alive = false; utilities.killProcess(procObj.pid, callback); } else { callback(); } } function installAppiumServer () { const installPath = path.join(__dirname, '..', '..'); logger.normal('paramedic-appium: Installing Appium server to ' + installPath); shell.pushd(installPath); return execPromise('npm install appium').then(() => { shell.popd(); }); } module.exports = AppiumRunner;