blob: 7bc463783ed2d33457a970a0f30148a0ebdca8a7 [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.
*/
/* jshint node: true */
"use strict";
// Run on iOS device:
// node cordova-medic/medic/medic.js appium --platform ios --device --udid c1e6ec7bb72473cfa14001ad49a2ab7dbbf7d69d --device-name "iPad 2" --platform-version "8.1" --app mobilespec
// Run on iOS emulator:
// node cordova-medic/medic/medic.js appium --platform ios --device-name "iPhone 5" --platform-version "8.4" --app mobilespec
// Run on Android device:
// node cordova-medic/medic/medic.js appium --platform android --device --platform-version "21" --app mobilespec
// Run on Android emulator:
// node cordova-medic/medic/medic.js appium --platform android --device-name appium --platform-version "21" -app mobilespec
var fs = require("fs");
var path = require("path");
var util = require("../lib/util");
var MedicReporter = require("../lib/MedicReporter");
var wd = require("wd");
var wdHelper = require("../lib/appium/helpers/wdHelper");
var screenshotHelper = require("../lib/appium/helpers/screenshotHelper");
var optimist = require("optimist");
var kill = require("tree-kill");
var child_process = require("child_process");
var expectTelnet = require("expect-telnet");
var shell = require("shelljs");
var Jasmine = require("jasmine");
var unorm = require("unorm");
var elementTree = require("elementtree");
var DEFAULT_APP_PATH = "mobilespec";
var DEFAULT_IOS_DEVICE_NAME = "iPhone 5";
var DEFAULT_ANDROID_DEVICE_NAME = "appium";
var DEFAULT_IOS_PLATFORM_VERSION = "7.1";
var DEFAULT_ANDROID_PLATFORM_VERSION = "19";
var KILL_SIGNAL = "SIGINT";
var SMALL_BUFFER_SIZE = 1024 * 1024;
var BIG_BUFFER_SIZE = 50 * 1024 * 1024;
var APPIUM_SERVER_PATH = path.normalize("cordova-medic/node_modules/appium/build/lib/main.js");
function getFullAppPath(appPath) {
return path.join(__dirname, "../..", appPath);
}
function getPackagePath(options) {
var fullAppPath = getFullAppPath(options.appPath);
switch (options.platform) {
case "android":
return path.join(fullAppPath, "/platforms/android/build/outputs/apk/android-debug.apk");
case "ios":
var searchDir = options.device ?
path.join(getFullAppPath(options.appPath), "/platforms/ios/build/device/") :
path.join(getFullAppPath(options.appPath), "/platforms/ios/build/emulator/");
var fileMask = options.device ? "*.ipa" : "*.app";
var files = shell.ls(searchDir + fileMask);
util.medicLog("Looking for app package in " + searchDir);
if (files && files.length > 0) {
util.medicLog("Found app package: " + files[0]);
return files[0];
}
util.fatal("Could not find the app package");
}
}
function getPluginDirs(appPath) {
return shell.ls(path.join(appPath, "/plugins/cordova-plugin-*"));
}
function parseArgs() {
// get args
var DEFAULT_DEVICE_NAME;
var DEFAULT_PLATFORM_VERSION;
var options = {};
var argv = optimist
.usage("Usage: $0 {options}")
.demand("platform")
.describe("platform", "A platform to run the tests on. Only \'ios\' and \'android\' are supported.")
.boolean("device")
.describe("device", "Run tests on real device.")
.default("app", DEFAULT_APP_PATH)
.describe("app", "Path to the test app.")
.default("udid", "")
.describe("udid", "UDID of the ios device. Only needed when running tests on real iOS devices.")
.demand("deviceName")
.describe("deviceName", "Name of the device/avd/simulator to run tests on.")
.demand("platformVersion")
.describe("platformVersion", "Version of the OS installed on the device or the emulator. For example, '21' for Android or '8.1' for iOS.")
.default("output", path.join(__dirname, "../../test_summary.json"))
.describe("output", "A file that will store test results")
.describe("plugins", "A space-separated list of plugins to test.")
.describe("screenshotPath", "A directory to save screenshots to, either absolute or relative to the directory containing cordova-medic.")
.describe("logFile", "A file to output Appium logs to.")
.argv;
// filling out the options object
options.platform = argv.platform.toLowerCase();
options.appPath = argv.app;
options.appiumDeviceName = argv.deviceName || DEFAULT_DEVICE_NAME;
options.appiumPlatformVersion = argv.platformVersion || DEFAULT_PLATFORM_VERSION;
options.udid = argv.udid;
options.device = argv.device;
if (argv.output) {
options.outputPath = path.normalize(argv.output);
}
if (argv.logFile) {
options.logFile = path.normalize(argv.logFile);
}
if (argv.screenshotPath) {
if (path.isAbsolute(argv.screenshotPath)){
options.screenshotPath = path.normalize(argv.screenshotPath);
} else {
options.screenshotPath = path.join(__dirname, "../..", argv.screenshotPath);
}
} else {
options.screenshotPath = path.join(__dirname, "../../appium_screenshots");
}
// accepting both "plugins" or "plugin" arguments
// if there is none, using default plugin list
if (argv.plugin) {
argv.plugins = argv.plugin;
}
if (argv.plugins) {
options.pluginRepos = argv.plugins.split(" ").map(function (pluginName) {
return path.join(options.appPath, "plugins", pluginName);
});
} else {
options.pluginRepos = getPluginDirs(options.appPath);
}
// looking for the tests
options.testPaths = [];
var searchPaths = [];
options.pluginRepos.forEach(function (pluginRepo) {
searchPaths.push(path.join(pluginRepo, "appium-tests", options.platform));
searchPaths.push(path.join(pluginRepo, "appium-tests", "common"));
});
searchPaths.forEach(function (searchPath) {
if (fs.existsSync(searchPath)) {
util.medicLog("Found tests in: " + searchPath);
options.testPaths.push(path.join(searchPath, "*.spec.js"));
}
});
// setting default values depending of the platform
switch (options.platform) {
case "android":
DEFAULT_DEVICE_NAME = DEFAULT_ANDROID_DEVICE_NAME;
DEFAULT_PLATFORM_VERSION = DEFAULT_ANDROID_PLATFORM_VERSION;
break;
case "ios":
DEFAULT_DEVICE_NAME = DEFAULT_IOS_DEVICE_NAME;
DEFAULT_PLATFORM_VERSION = DEFAULT_IOS_PLATFORM_VERSION;
break;
default:
util.fatal("Unsupported platform: " + options.platform);
break;
}
// fail if the user forgot to specify UDID when running on real iOS device
if (options.platform === "ios" && options.device && !options.udid) {
util.fatal("Please supply device UDID by using --udid argument when running on real iOS device." +
"More info on finding out your UDID: https://www.innerfence.com/howto/find-iphone-unique-device-identifier-udid");
}
// fail if we couldn't locate the tests
if (options.testPaths.length === 0) {
util.fatal("Couldn't find the tests. Please check that the plugin repos are cloned.");
}
// 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 = options.device;
global.PLATFORM = options.platform;
global.PLATFORM_VERSION = options.appiumPlatformVersion;
global.DEVICE_NAME = options.appiumDeviceName;
global.SCREENSHOT_PATH = options.screenshotPath;
global.UNORM = unorm;
global.UDID = options.udid;
// creating a directory to save screenshots to
fs.stat(global.SCREENSHOT_PATH, function (err) {
if (err) {
fs.mkdir(global.SCREENSHOT_PATH);
}
});
return options;
}
function getLocalCLI(appPath) {
if (util.isWindows() || !fs.existsSync(path.join(getFullAppPath(appPath), "./cordova"))) {
// fall back to globally installed cordova if the app is not mobilespec
return "cordova";
}
return "./cordova";
}
function getConfigPath(appPath) {
return path.join(appPath, "config.xml");
}
function parseElementtreeSync(filename) {
var contents = fs.readFileSync(filename, util.DEFAULT_ENCODING);
if (!contents) {
util.fatal("The config file is empty: " + filename);
}
// Skip the Byte Order Mark (BOM)
contents = contents.substring(contents.indexOf("<"));
return new elementTree.ElementTree(elementTree.XML(contents));
}
function addCspSource(appPath, directive, source) {
var cspInclFile = path.join(appPath, "www/csp-incl.js");
var indexFile = path.join(appPath, "www/index.html");
var cspFile = fs.existsSync(cspInclFile) ? cspInclFile : indexFile;
var cspContent = fs.readFileSync(cspFile, util.DEFAULT_ENCODING);
var cspTagOpening = "<meta http-equiv=\"Content-Security-Policy\" content=\"";
var cspRule = directive + " " + source;
var cspRuleReg = new RegExp(directive + "[^;\"]+" + source.replace("*", "\\*"));
util.medicLog("Adding CSP source \"" + source + "\" to directive \"" + directive + "\"");
if (cspContent.match(cspRuleReg)) {
util.medicLog("It's already there.");
} else if (util.contains(cspContent, directive)) {
// if the directive is there, just add the source to it
cspContent = cspContent.replace(directive, cspRule);
fs.writeFileSync(cspFile, cspContent, util.DEFAULT_ENCODING);
} else if (cspContent.match(/content=".*?default-src.+?"/)) {
// needed directive is not there but there is default-src directive
// creating needed directive and copying default-src sources to it
var defaultSrcReg = /(content=".*?default-src)(.+?);/;
cspContent = cspContent.replace(defaultSrcReg, "$1$2; " + cspRule + "$2;");
fs.writeFileSync(cspFile, cspContent, util.DEFAULT_ENCODING);
} else if (util.contains(cspContent, cspTagOpening)) {
// needed directive is not there and there is no default-src directive
// but the CSP tag is till present
// just adding needed directive to a start of CSP tag content
cspContent = cspContent.replace(cspTagOpening, cspTagOpening + directive + " " + source + "; ");
fs.writeFileSync(cspFile, cspContent, util.DEFAULT_ENCODING);
} else {
// no CSP tag, skipping
util.medicLog("WARNING: No CSP tag found.");
}
}
function setPreference(appPath, preference, value) {
var configFile = getConfigPath(appPath);
var xml = parseElementtreeSync(configFile);
var pref = xml.find("preference[@name=\"" + preference + "\"]");
util.medicLog("Setting \"" + preference + "\" preference to \"" + value + "\"");
if (!pref) {
pref = new elementTree.Element("preference");
pref.attrib.name = preference;
xml.getroot().append(pref);
}
pref.attrib.value = value;
// write the changes
fs.writeFileSync(configFile, xml.write({indent: 4}), util.DEFAULT_ENCODING);
}
function permitAccess(appPath, origin) {
var configFile = getConfigPath(appPath);
var xml = parseElementtreeSync(configFile);
var rule = xml.find("access[@origin=\"" + origin + "\"]");
util.medicLog("Adding a whitelist 'access' rule for origin: " + origin);
if (rule) {
util.medicLog("It is already in place");
} else {
rule = new elementTree.Element("access");
rule.attrib.origin = origin;
xml.getroot().append(rule);
fs.writeFileSync(configFile, xml.write({indent: 4}), util.DEFAULT_ENCODING);
}
}
// remove medic.json and rebuild the app
function prepareApp(options, callback) {
var fullAppPath = getFullAppPath(options.appPath);
var deviceString = options.device ? " --device" : "";
var buildCommand = getLocalCLI(options.appPath) + " build " + options.platform + deviceString;
// remove medic.json and (re)build
shell.rm(path.join(fullAppPath, "www", "medic.json"));
fs.stat(fullAppPath, function (error, stats) {
// check if the app exists
if (error || !stats.isDirectory()) {
util.fatal("The app directory doesn't exist: " + fullAppPath);
}
// set properties/CSP rules
if (options.platform === "ios") {
setPreference(fullAppPath, "CameraUsesGeolocation", "true");
}
addCspSource(fullAppPath, "connect-src", "http://*");
permitAccess(fullAppPath, "*");
// rebuild the app
util.medicLog("Building the app...");
child_process.exec(buildCommand, { cwd: fullAppPath, maxBuffer: SMALL_BUFFER_SIZE }, function (error) {
if (error) {
util.fatal("Couldn't build the app: " + error);
} else {
global.PACKAGE_PATH = getPackagePath(options);
callback();
}
});
});
}
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, killSignal, callback) {
if (procObj.alive) {
procObj.alive = false;
setTimeout(function () {
kill(procObj.process.pid, killSignal, callback);
}, 1000);
} else {
callback();
}
}
function saveResults(results, outputPath, callback) {
if (typeof callback !== "function") {
callback = function () { return; };
}
// write out results if an output path was passed
if (outputPath) {
util.medicLog("Saving test run results to " + outputPath);
fs.writeFile(outputPath, JSON.stringify(results) + "\n", util.DEFAULT_ENCODING, function (error) {
if (error) {
util.fatal("Error writing test results: " + error.message);
} else {
callback();
}
});
}
}
// TODO: use this function when we get stable Appium results
function summarizeAndSaveResults(results, outputPath, callback) {
fs.stat(outputPath, function (error, stats) {
if (!error && stats.isFile()) {
fs.readFile(outputPath, util.DEFAULT_ENCODING, function (err, data) {
if (!err) {
var obj = JSON.parse(data);
util.medicLog("Found autotests results:");
if (obj.hasOwnProperty("total")) {
util.medicLog("Adding " + results.total + " total from Appium to " + obj.total + " total from autotests");
results.total += obj.total;
}
if (obj.hasOwnProperty("failed")) {
util.medicLog("Adding " + results.failed + " failed from Appium to " + obj.failed + " failed from autotests");
results.failed += obj.failed;
}
if (obj.hasOwnProperty("passed")) {
util.medicLog("Adding " + results.passed + " passed from Appium to " + obj.passed + " passed from autotests");
results.passed += obj.passed;
}
if (obj.hasOwnProperty("warnings")) {
util.medicLog("Adding " + results.warnings + " warnings from Appium to " + obj.warnings + " warnings from autotests");
results.warnings += obj.warnings;
}
}
saveResults(results, callback);
});
} else {
saveResults(results, callback);
}
});
}
function allDoneCallback(appium, iosProxy, results) {
killProcess(appium, KILL_SIGNAL, function () {
killProcess(iosProxy, KILL_SIGNAL, function () {
var exitCode;
if (!results) {
exitCode = 1;
} else {
exitCode = results.failed === 0 ? 0 : 1;
}
util.medicLog("Exiting with exit code " + exitCode);
process.exit(exitCode);
});
});
}
function startTests(testPaths, appium, iosProxy) {
var jasmine = new Jasmine();
var medicReporter;
function exitGracefully(e) {
util.medicLog("Uncaught exception! Killing server and exiting in 2 seconds...");
killProcess(appium, KILL_SIGNAL, function () {
killProcess(iosProxy, KILL_SIGNAL, function () {
setTimeout(function () {
util.fatal(e.stack);
}, 2000);
});
});
}
process.on("uncaughtException", function(err) {
exitGracefully(err);
});
util.medicLog("Running tests from:");
testPaths.forEach(function (testPath) {
util.medicLog(testPath);
});
jasmine.loadConfig({
spec_dir: "",
spec_files: testPaths
});
medicReporter = new MedicReporter(function (results) {
allDoneCallback(appium, iosProxy, results);
});
// don't use default reporter, it exits the process before
// we would get the chance to kill appium server
//jasmine.configureDefaultReporter({ showColors: false });
jasmine.addReporter(medicReporter);
try {
// Launch the tests!
jasmine.execute();
} catch (e) {
exitGracefully(e);
}
}
function startIosProxy(options) {
var iosProxyCommand;
var iosProxy = {
alive: false,
process: null
};
if (options.platform === "ios" && options.device && options.udid) {
iosProxyCommand = "ios_webkit_debug_proxy -c " + options.udid + ":27753";
util.medicLog("Running:");
util.medicLog(iosProxyCommand);
iosProxy.alive = true;
iosProxy.process = child_process.exec(iosProxyCommand, { maxBuffer: BIG_BUFFER_SIZE }, function () {
iosProxy.alive = false;
util.medicLog("iOS proxy process exited.");
});
}
return iosProxy;
}
function startAppiumServer(options, iosProxy, callback) {
var appiumPlatformName;
var appiumServerCommand;
var additionalArgs = "";
var appium = {
alive: false,
process: null
};
// compose a command to run the Appium server
switch (options.platform) {
case "android":
appiumPlatformName = "Android";
if (!options.device) {
additionalArgs += " --avd " + options.appiumDeviceName;
}
break;
case "ios":
appiumPlatformName = "iOS";
if (options.udid) {
additionalArgs += " --udid " + options.udid;
}
break;
default:
throw new Error("Unsupported platform: " + options.platform);
}
if (options.logFile) {
additionalArgs += " --log " + options.logFile;
}
appiumServerCommand = "node " + APPIUM_SERVER_PATH +
additionalArgs;
// run the Appium server
util.medicLog("Running:");
util.medicLog(appiumServerCommand);
appium.alive = true;
appium.process = child_process.exec(appiumServerCommand, { maxBuffer: BIG_BUFFER_SIZE }, function (error) {
util.medicLog("Appium process exited.");
if (appium.alive && error) {
util.medicLog("Error running appium server: " + error);
if (isFailFastError(error)) {
allDoneCallback(appium, iosProxy);
} else {
util.medicLog("Another instance already running? Will try to run tests on it.");
callback(appium);
}
}
appium.alive = false;
});
// Wait for the Appium server to start up
appium.process.stdout.on("data", function (data) {
if (data.indexOf("Appium REST http interface listener started") > -1) {
callback(appium);
}
});
}
function main() {
var options = parseArgs();
prepareApp(options, function () {
var iosProxy = startIosProxy(options);
startAppiumServer(options, iosProxy, function (appium) {
startTests(options.testPaths, appium, iosProxy);
});
});
}
main();