| #!/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(); |