blob: 905b8ada31b61a980010bfba7828347166b1ece2 [file] [log] [blame]
#!/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 (!this.options.hasOwnProperty('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 appiumServerCommand;
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;
}
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 = [];
let 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;