diff --git a/.gitignore b/.gitignore index 04f0265e5..6ac3a22e0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ app/android/AndroidManifest.xml *~ uiautomator/bootstrap/target/ org.eclipse.ltk.core.refactoring.prefs +/selendroid diff --git a/.gitmodules b/.gitmodules index de30a3944..1aacce35e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "submodules/appium.io"] path = submodules/appium.io url = https://github.com/appium/appium.io.git +[submodule "submodules/selendroid"] + path = submodules/selendroid + url = https://github.com/DominikDary/selendroid.git diff --git a/app/android.js b/app/android.js index e7f319eee..485bb3fda 100644 --- a/app/android.js +++ b/app/android.js @@ -72,6 +72,7 @@ Android.prototype.start = function(cb, onDie) { var skipRelaunchOn = [ 'App never showed up' , 'Could not sign one or more apks' + , 'Could not find a connected Android device' ]; var checkShouldSkipRelaunch = function(msg) { var skip = false; @@ -127,7 +128,7 @@ Android.prototype.start = function(cb, onDie) { if (this.adb === null) { // Pass Android opts and Android ref to adb. this.adb = adb(this.opts, this); - this.adb.start(onLaunch, onExit); + this.adb.startAppium(onLaunch, onExit); } else { logger.error("Tried to start ADB when we already have one running!"); } diff --git a/app/appium.js b/app/appium.js index 0df035dd8..b7c02637f 100644 --- a/app/appium.js +++ b/app/appium.js @@ -15,6 +15,7 @@ var routing = require('./routing') , _ = require('underscore') , ios = require('./ios') , android = require('./android') + , selendroid = require('./selendroid') , status = require("./uiauto/lib/status"); var Appium = function(args) { @@ -108,6 +109,8 @@ Appium.prototype.getDeviceType = function(desiredCaps) { return "ios"; } else if (desiredCaps.device.toLowerCase().indexOf('ipad') !== -1) { return "ios"; + } else if (desiredCaps.device.toLowerCase().indexOf('selendroid') !== -1) { + return "selendroid"; } else { return "android"; } @@ -116,6 +119,8 @@ Appium.prototype.getDeviceType = function(desiredCaps) { return "ios"; } else if (desiredCaps.browserName.toLowerCase() === "safari") { return "ios"; + } else if (desiredCaps.browserName.toLowerCase().indexOf('selendroid') !== -1) { + return "selendroid"; } else { return "android"; } @@ -147,6 +152,10 @@ Appium.prototype.isAndroid = function() { return this.deviceType === "android"; }; +Appium.prototype.isSelendroid = function() { + return this.deviceType === "selendroid"; +}; + Appium.prototype.getAppExt = function() { return this.isIos() ? ".app" : ".apk"; }; @@ -352,6 +361,17 @@ Appium.prototype.invoke = function() { , fastReset: this.args.fastReset }; this.devices[this.deviceType] = android(androidOpts); + } else if (this.isSelendroid()) { + var selendroidOpts = { + apkPath: this.args.app + , desiredCaps: this.desiredCapabilities + , verbose: this.args.verbose + , udid: this.args.udid + , appPackage: this.args.androidPackage + , appActivity: this.args.androidActivity + , appDeviceReadyTimeout: this.args.androidDeviceReadyTimeout + }; + this.devices[this.deviceType] = selendroid(selendroidOpts); } else { throw new Error("Tried to start a device that doesn't exist: " + this.deviceType); @@ -359,13 +379,17 @@ Appium.prototype.invoke = function() { } this.device = this.devices[this.deviceType]; - this.device.start(function(err) { + this.device.start(function(err, sessionIdOverride) { me.progress++; // Ensure we don't use an undefined session. if (typeof me.sessions[me.progress] === 'undefined') { me.progress--; return; } + if (sessionIdOverride) { + me.sessionId = sessionIdOverride; + logger.info("Overriding session id with " + sessionIdOverride); + } me.sessions[me.progress].sessionId = me.sessionId; me.sessions[me.progress].callback(err, me.device); diff --git a/app/device.js b/app/device.js index 47f8d7534..58d943739 100644 --- a/app/device.js +++ b/app/device.js @@ -1,6 +1,8 @@ "use strict"; var errors = require('./errors') + , request = require('request') + , _ = require('underscore') , logger = require('../logger').get('appium'); var UnknownError = errors.UnknownError @@ -57,3 +59,29 @@ exports.waitForCondition = function(waitMs, condFn, cb, intervalMs) { }; spin(); }; + +exports.request = function(url, method, body, contentType, cb) { + if (typeof cb === "undefined" && typeof contentType === "function") { + cb = contentType; + contentType = null; + } + if (typeof contentType === "undefined" || contentType === null) { + contentType = "application/json"; + } + if (!(/^https?:\/\//.exec(url))) { + url = 'http://' + url; + } + var opts = { + url: url + , method: method + , headers: {'Content-Type': contentType} + }; + if (_.contains(['put', 'post', 'patch'], method.toLowerCase())) { + if (typeof body !== "string") { + opts.json = body; + } else { + opts.body = body; + } + } + request(opts, cb); +}; diff --git a/app/helpers.js b/app/helpers.js index 6d6a4167a..738b9f8bc 100644 --- a/app/helpers.js +++ b/app/helpers.js @@ -8,6 +8,7 @@ var logger = require('../logger').get('appium') , path = require('path') , rimraf = require('rimraf') , exec = require('child_process').exec + , util = require('util') , temp = require('temp'); exports.downloadFile = function(fileUrl, cb) { diff --git a/app/routing.js b/app/routing.js index fbd92d237..06ad0a001 100644 --- a/app/routing.js +++ b/app/routing.js @@ -1,18 +1,55 @@ "use strict"; var controller = require('./controller') + , request = require('./device').request + , _ = require('underscore') , logger = require('../logger').get('appium'); +var shouldProxy = function(req) { + if (req.device === null) return false; + + var avoid = [ + ['POST', new RegExp('^/wd/hub/session$')] + , ['DELETE', new RegExp('^/wd/hub/session/[^/]+$')] + ]; + var method = req.route.method.toUpperCase(); + var path = req.originalUrl; + var shouldAvoid = false; + _.each(avoid, function(pathToAvoid) { + if (method === pathToAvoid[0] && pathToAvoid[1].exec(path)) { + shouldAvoid = true; + } + }); + return !shouldAvoid; +}; + + module.exports = function(appium) { - var rest = appium.rest - , inject = function(req, res, next) { - req.appium = appium; - req.device = appium.device; - logger.debug("Appium request initiated at " + req.url); - if (typeof req.body === "object") { - logger.debug("Request received with params: " + JSON.stringify(req.body)); - } - next(); - }; + var rest = appium.rest; + var inject = function(req, res, next) { + req.appium = appium; + req.device = appium.device; + logger.debug("Appium request initiated at " + req.url); + if (typeof req.body === "object") { + logger.debug("Request received with params: " + JSON.stringify(req.body)); + } + if (shouldProxy(req)) { + logger.debug("Proxying command to " + req.device.proxyHost + ":" + + req.device.proxyPort); + var url = 'http://' + req.device.proxyHost + ':' + req.device.proxyPort + + req.originalUrl; + request(url, req.route.method.toUpperCase(), req.body, + req.headers['content-type'], function(err, response, body) { + if (err) return next(err); + logger.debug("Proxied response received with status " + + response.statusCode + ": " + + JSON.stringify(body).slice(0, 1000)); + res.headers = response.headers; + res.send(response.statusCode, body); + }); + } else { + next(); + } + }; // Make appium available to all REST http requests. rest.all('/wd/*', inject); diff --git a/app/selendroid.js b/app/selendroid.js new file mode 100644 index 000000000..53c626319 --- /dev/null +++ b/app/selendroid.js @@ -0,0 +1,156 @@ +"use strict"; + +var errors = require('./errors') + , adb = require('../uiautomator/adb') + , _ = require('underscore') + , request = require('./device').request + , logger = require('../logger').get('appium') + , status = require("./uiauto/lib/status") + , exec = require('child_process').exec + , fs = require('fs') + , ncp = require('ncp') + , async = require('async') + , path = require('path') + , UnknownError = errors.UnknownError; + +var Selendroid = function(opts) { + this.opts = opts; + this.serverApk = null; + this.appPackage = opts.appPackage; + this.desiredCaps = opts.desiredCaps; + this.onStop = function() {}; + this.selendroidSessionId = null; + this.adb = null; + this.isProxy = true; + this.proxyHost = 'localhost'; + this.proxyPort = 8080; +}; + +Selendroid.prototype.start = function(cb) { + logger.info("Starting selendroid server"); + var opts = _.clone(this.opts) + , me = this; + opts.port = this.proxyPort; + opts.devicePort = 8080; + this.adb = new adb(opts); + + async.waterfall([ + function(cb) { me.ensureServerExists(cb); }, + function(cb) { me.adb.startSelendroid(me.serverApk, cb); }, + function(res, cb) { me.waitForServer(cb); }, + function(cb) { me.createSession(cb); }, + ], cb); +}; + +Selendroid.prototype.stop = function(cb) { + logger.info("Stopping selendroid server"); + this.deleteSession(function(err) { + cb(err ? 1 : 0); + }); +}; + +Selendroid.prototype.ensureServerExists = function(cb) { + logger.info("Checking whether selendroid is built for package yet"); + var fileName = 'selendroid-' + this.appPackage + '.apk'; + var filePath = path.resolve(__dirname, "../selendroid/selendroid-server", + "target", fileName); + fs.stat(filePath, _.bind(function(err) { + if (err) { + logger.info("Selendroid needs to be built"); + return this.buildServer(cb); + } + logger.info("Selendroid server already exists, not rebuilding"); + this.serverApk = filePath; + cb(null); + }, this)); +}; + +Selendroid.prototype.buildServer = function(cb) { + logger.info("Building selendroid server for package " + this.appPackage); + var buildDir = path.resolve(__dirname, "../selendroid/selendroid-server"); + var src = buildDir + "/target/selendroid-server-0.3.apk"; + var dest = buildDir + "/target/selendroid-" + this.appPackage + '.apk'; + var cmd = "mvn install -Dandroid.renameInstrumentationTargetPackage=" + + this.appPackage; + exec(cmd, {cwd: buildDir}, _.bind(function(err, stdout, stderr) { + if (err) { + logger.error("Unable to build selendroid server. Stdout was: "); + logger.error(stdout); + logger.error(stderr); + return cb(err); + } + logger.info("Copying selendroid server to correct destination"); + ncp(src, dest, _.bind(function(err) { + if (err) { + logger.error("Error copying selendroid to destination"); + return cb(err); + } + logger.info("Selendroid server copied successfully"); + this.serverApk = dest; + cb(null); + }, this)); + }, this)); +}; + +Selendroid.prototype.waitForServer = function(cb) { + var waitMs = 20000 + , intMs = 800 + , start = Date.now(); + + var pingServer = _.bind(function() { + this.proxyTo('/wd/hub/status', 'GET', null, function(err, res, body) { + if (body === null || typeof body === "undefined" || !body.trim()) { + if (Date.now() - start < waitMs) { + setTimeout(pingServer, intMs); + } else { + cb(new Error("Waited " + (waitMs / 1000) + " secs for " + + "selendroid server and it never showed up")); + } + } else { + logger.info("Selendroid server is alive!"); + cb(null); + } + }); + }, this); + + pingServer(); +}; + +Selendroid.prototype.createSession = function(cb) { + var data = {desiredCapabilities: this.desiredCaps}; + this.proxyTo('/wd/hub/session', 'POST', data, _.bind(function(err, res, body) { + if (err) return cb(err); + + if (res.statusCode === 301 && body.sessionId) { + logger.info("Successfully started selendroid session"); + this.selendroidSessionId = body.sessionId; + cb(null, body.sessionId); + } else { + logger.error("Selendroid create session did not work. Status was " + + res.statusCode + " and body was " + body); + cb(new Error("Did not get session redirect from selendroid")); + } + }, this)); +}; + +Selendroid.prototype.deleteSession = function(cb) { + var url = '/wd/hub/session/' + this.selendroidSessionId; + this.proxyTo(url, 'DELETE', null, _.bind(function(err, res) { + if (err) return cb(err); + if (res.statusCode !== 200) return cb(new Error("Status was not 200")); + cb(); + }, this)); +}; + +Selendroid.prototype.proxyTo = function(endpoint, method, data, cb) { + if (endpoint[0] !== '/') { + endpoint = '/' + endpoint; + } + var url = 'http://' + this.proxyHost + ':' + this.proxyPort + endpoint; + request(url, method, data ? data : null, cb); +}; + +module.exports = function(opts) { + return new Selendroid(opts); +}; + diff --git a/package.json b/package.json index ba9f6c2e8..26463170f 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "ncp": "~0.4.2", "swig": "~0.13.5", "async": "~0.2.6", + "mkdirp": "~0.3.5", "binary-cookies": "~0.1.1" }, "scripts": { diff --git a/reset.sh b/reset.sh index 68d9ca7fd..c66fdb48b 100755 --- a/reset.sh +++ b/reset.sh @@ -7,20 +7,24 @@ set -e should_reset_android=0 should_reset_ios=0 +should_reset_selendroid=0 +appium_home=$(pwd) +selendroid_ver="0.3" while test $# != 0 do - echo $1 case "$1" in "--android") should_reset_android=1;; "--ios") should_reset_ios=1;; + "--selendroid") should_reset_selendroid=1;; esac shift done -if [[ $should_reset_android -eq 0 ]] && [[ $should_reset_ios -eq 0 ]]; then +if [[ $should_reset_android -eq 0 ]] && [[ $should_reset_ios -eq 0 ]] && [[ $should_reset_selendroid -eq 0 ]]; then should_reset_android=1 should_reset_ios=1 + should_reset_selendroid=1 fi reset_general() { @@ -63,12 +67,16 @@ reset_ios() { grunt buildApp:WebViewApp } -reset_android() { - echo "---- RESETTING ANDROID ----" +get_apidemos() { echo "Downloading/updating AndroidApiDemos" git submodule update --init submodules/ApiDemos rm -rf sample-code/apps/ApiDemos - ln -s $(pwd)/submodules/ApiDemos $(pwd)/sample-code/apps/ApiDemos + ln -s $appium_home/submodules/ApiDemos $appium_home/sample-code/apps/ApiDemos +} + +reset_android() { + echo "---- RESETTING ANDROID ----" + get_apidemos echo "Building Android bootstrap" grunt configAndroidBootstrap grunt buildAndroidBootstrap @@ -77,6 +85,16 @@ reset_android() { grunt buildAndroidApp:ApiDemos } +reset_selendroid() { + echo "---- RESETTING SELENDROID ----" + get_apidemos + echo "Downloading/updating selendroid" + rm -rf submodules/selendroid/selendroid-server/target + git submodule update --init submodules/selendroid + rm -rf selendroid + ln -s $appium_home/submodules/selendroid $appium_home/selendroid +} + cleanup() { echo "---- CLEANING UP ----" echo "Cleaning temp files" @@ -92,4 +110,7 @@ fi if [ $should_reset_android -eq 1 ]; then reset_android fi +if [ $should_reset_selendroid -eq 1 ]; then + reset_selendroid +fi cleanup diff --git a/submodules/selendroid b/submodules/selendroid new file mode 160000 index 000000000..f3b5e7fde --- /dev/null +++ b/submodules/selendroid @@ -0,0 +1 @@ +Subproject commit f3b5e7fdeaf23955aef1bc145711fe5f1fe2da63 diff --git a/test/functional/selendroid/basic.js b/test/functional/selendroid/basic.js new file mode 100644 index 000000000..c2c4d6520 --- /dev/null +++ b/test/functional/selendroid/basic.js @@ -0,0 +1,34 @@ +/*global it:true */ +"use strict"; + +var path = require('path') + , appPath = path.resolve(__dirname, "../../../sample-code/apps/ApiDemos/bin/ApiDemos-debug.apk") + , appPkg = "com.example.android.apis" + , appAct = "ApiDemos" + , driverBlock = require("../../helpers/driverblock.js") + , describeWd = driverBlock.describeForApp(appPath, "selendroid", appPkg, appAct) + , should = require('should'); + + describeWd('basic', function(h) { + it('should find and click an element', function(done) { + // selendroid appears to have some issues with implicit waits + // hence the timeouts + setTimeout(function() { + h.driver.elementByName('App', function(err, el) { + should.not.exist(err); + should.exist(el); + el.click(function(err) { + should.not.exist(err); + setTimeout(function() { + h.driver.elementByLinkText("Action Bar", function(err, el) { + should.not.exist(err); + should.exist(el); + done(); + }); + }, 1000); + }); + }); + }, 1000); + }); + }); + diff --git a/test/helpers/driverblock.js b/test/helpers/driverblock.js index 1851e8e06..751d46b85 100644 --- a/test/helpers/driverblock.js +++ b/test/helpers/driverblock.js @@ -108,22 +108,24 @@ var describeForApp = function(app, device, appPackage, appActivity) { if (typeof device === "undefined") { device = "ios"; } - var browserName, appPath; + var browserName, appPath, realDevice; if (device === "ios") { + realDevice = "iPhone Simulator"; browserName = "iOS"; } else if (device === "android") { - browserName = "Android"; + browserName = realDevice = "Android"; + } else if (device === "selendroid") { + browserName = realDevice = "Selendroid"; } if (/\//.exec(app) || /\./.exec(app)) { appPath = app; } else { if (device === "ios") { appPath = path.resolve(__dirname, "../../sample-code/apps/" + app + "/build/Release-iphonesimulator/" + app + ".app"); - } else if (device === "android") { + } else if (device === "android" || device === "selendroid") { appPath = path.resolve(__dirname, "../../sample-code/apps/" + app + "/bin/" + app + "-debug.apk"); } } - var realDevice = device == "ios" ? "iPhone Simulator" : "Android"; return function(desc, tests, host, port, caps, extraCaps) { if (typeof extraCaps === "undefined") { diff --git a/uiautomator/adb.js b/uiautomator/adb.js index 8b3fb19c4..904fae682 100644 --- a/uiautomator/adb.js +++ b/uiautomator/adb.js @@ -11,6 +11,7 @@ var spawn = require('child_process').spawn , testZipArchive = require('../app/helpers').testZipArchive , async = require('async') , ncp = require('ncp') + , mkdirp = require('mkdirp') , _ = require('underscore'); var noop = function() {}; @@ -27,7 +28,8 @@ var ADB = function(opts, android) { // Don't uninstall if using fast reset. // Uninstall if reset is set and fast reset isn't. this.skipUninstall = opts.fastReset || !(opts.reset || false); - this.port = opts.port || 4724; + this.systemPort = opts.port || 4724; + this.devicePort = opts.devicePort || 4724; this.avdName = opts.avdName; this.appPackage = opts.appPackage; this.appActivity = opts.appActivity; @@ -45,6 +47,7 @@ var ADB = function(opts, android) { this.portForwarded = false; this.debugMode = true; this.fastReset = opts.fastReset; + this.cleanApp = opts.cleanApp || this.fastReset; this.cleanAPK = '/tmp/' + this.appPackage + '.clean.apk'; // This is set to true when the bootstrap jar crashes. this.restartBootstrap = false; @@ -54,193 +57,192 @@ var ADB = function(opts, android) { this.cmdCb = null; }; -ADB.prototype.checkAdbPresent = function(cb) { +ADB.prototype.checkSdkBinaryPresent = function(binary, cb) { + var binaryLoc = null; if (this.sdkRoot) { - this.adb = path.resolve(this.sdkRoot, "platform-tools", "adb"); - this.debug("Using adb from " + this.adb); - cb(null, this); + binaryLoc = path.resolve(this.sdkRoot, "platform-tools", binary); + this.debug("Using " + binary + " from " + binaryLoc); + cb(null, binaryLoc); } else { - exec("which adb", _.bind(function(err, stdout) { + exec("which " + binary, _.bind(function(err, stdout) { if (stdout) { - this.debug("Using adb from " + stdout); - this.adb = stdout; - cb(null, this); + this.debug("Using " + binary + " from " + stdout); + cb(null, stdout); } else { - cb("Could not find adb; do you have android SDK installed?", null); + cb(new Error("Could not find " + binary + "; do you have android " + + "SDK installed?"), + null); } }, this)); } }; +ADB.prototype.checkAdbPresent = function(cb) { + this.checkSdkBinaryPresent("adb", _.bind(function(err, binaryLoc) { + if (err) return cb(err); + this.adb = binaryLoc; + cb(null); + }, this)); +}; + +ADB.prototype.retargetManifest = function(newPkg, newTarget, inFile, outFile, cb) { + var data = fs.readFileSync(inFile, "utf8"); + var rePkg = /package="([^"]+)"/; + var reTarget = /targetPackage="([^"]+)"/; + var matchPkg = rePkg.exec(data); + var matchTarget = reTarget.exec(data); + + if (newPkg && !matchPkg) { + logger.debug("Could not find package= in manifest"); + return cb("could not find package= in manifest"); + } + + if (newTarget && !matchTarget) { + logger.debug("Could not find targetPackage= in manifest"); + return cb("could not find targetPackage= in manifest"); + } + + if (newPkg) { + var newPkgData = matchPkg[0].replace(matchPkg[1], newPkg); + data = data.replace(matchPkg[0], newPkgData); + } + + if (newTarget) { + var newTargetData = matchTarget[0].replace(matchTarget[1], newTarget); + data = data.replace(matchTarget[0], newTargetData); + } + + fs.writeFileSync(outFile, data, "utf8"); + logger.debug("Created manifest"); + cb(null); +}; + // Fast reset ADB.prototype.buildFastReset = function(skipAppSign, cb) { // Create manifest var me = this; var targetAPK = me.apkPath; - var targetPackage = me.appPackage; - // gsub AndroidManifest.xml using targetPackage - var manifestPath = path.resolve(__dirname, '../app/android/AndroidManifest.xml.src'); + var cleanAPKSrc = path.resolve(__dirname, '../app/android/Clean.apk'); + var newPackage = me.appPackage + '.clean'; + var inFile = path.resolve(__dirname, '../app/android/AndroidManifest.xml.src'); + var outFile = inFile.substr(0, inFile.length - '.src'.length); - var utf8 = 'utf8'; - var data = fs.readFileSync(manifestPath, utf8); - var manifest = manifestPath.substr(0, manifestPath.length - '.src'.length); + var resignApks = function(cb) { + // Resign clean apk and target apk + var apks = [ me.cleanAPK ]; + if (!skipAppSign) { + logger.debug("Signing app and clean apk."); + apks.push(targetAPK); + } else { + logger.debug("Skip app sign. Sign clean apk."); + } + me.sign(apks, cb); + }; - var rePkg = /package="([^"]+)"/; - var reTarget = /targetPackage="([^"]+)"/; - var matchPkg = rePkg.exec(data); - var matchTarget = reTarget.exec(data); - - if (!matchPkg) { - logger.debug("Could not find package= in manifest"); - return cb("could not find package= in manifest"); - } - - if (!matchTarget) { - logger.debug("Could not find targetPackage= in manifest"); - return cb("could not find targetPackage= in manifest"); - } - - var newPkg = matchPkg[0].replace(matchPkg[1], targetPackage + '.clean'); - var newTarget = matchTarget[0].replace(matchTarget[1], targetPackage); - - var newContent = data.replace(matchPkg[0], newPkg); - newContent = newContent.replace(matchTarget[0], newTarget); - - fs.writeFileSync(manifest, newContent, utf8); - logger.debug("Created manifest"); - - async.series( - [ - function(cb) { - me.compileManifest(function(err) { if (err) return cb(err); cb(null); }, manifest); - }, - function(cb) { - me.insertManifest(manifest, skipAppSign, function(err) { - if (err) return cb(err); - cb(null); - }); - }, - ], - // Invoke top level function cb - function(err) { cb(err); } - ); + async.series([ + function(cb) { me.retargetManifest(newPackage, me.appPackage, inFile, outFile, cb); }, + function(cb) { me.checkSdkBinaryPresent("aapt", cb); }, + function(cb) { me.compileManifest(outFile, cb); }, + function(cb) { me.insertManifest(outFile, cleanAPKSrc, me.cleanAPK, cb); }, + function(cb) { resignApks(cb); } + ], cb); }; -ADB.prototype.compileManifest = function(cb, manifest) { - async.series( - [ - function(cb) { - exec('which aapt', function(err, stdout) { - if (!stdout) { - return cb(new Error("Error finding aapt binary, is it on your path?")); - } - cb(null); - }); - }, - function(cb) { - var androidHome = process.env.ANDROID_HOME; +ADB.prototype.insertSelendroidManifest = function(serverPath, cb) { + var me = this + , newServerPath = me.selendroidServerPath + , srcManifest = path.resolve(__dirname, "../selendroid/selendroid-gem", + "selendroid-prebuild/AndroidManifest.xml") + , dstDir = '/tmp/' + this.appPackage + , dstManifest = dstDir + '/AndroidManifest.xml'; - var platforms = androidHome + '/platforms/'; - var platform = 'android-17'; - - // android-17 may be called android-4.2 - if (!fs.existsSync(platforms + platform)) { - platform = 'android-4.2'; - - if (!fs.existsSync(platforms + platform)) { - return cb("Platform doesn't exist " + platform); - } - } - - // Compile manifest into manifest.xml.apk - var compile_manifest = ['aapt package -M "', manifest, - '" -I "', platforms + platform + '/android.jar" -F "', - manifest, '.apk" -f'].join(''); - logger.debug(compile_manifest); - exec(compile_manifest, {}, function(err, stdout, stderr) { - if (err) { - logger.debug(stderr); - return cb("error compiling manifest"); - } - logger.debug("Compiled manifest"); - cb(null); - }); - }, - ], - // Invoke top level function cb - function(err) { cb(err); } - ); + async.series([ + function(cb) { mkdirp(dstDir, cb); }, + function(cb) { me.retargetManifest(null, me.appPackage, srcManifest, + dstManifest, cb); }, + function(cb) { me.checkSdkBinaryPresent("aapt", cb); }, + function(cb) { me.compileManifest(dstManifest, cb); }, + function(cb) { me.insertManifest(dstManifest, serverPath, newServerPath, + cb); } + ], cb); }; -ADB.prototype.insertManifest = function(manifest, skipAppSign, cb) { - var me = this; - var targetAPK = me.apkPath; - var cleanAPK = me.cleanAPK; - async.series( - [ - // Extract compiled manifest from manifest.xml.apk - function(cb) { - unzipFile(manifest + '.apk', function(err, stderr) { - if (err) { - logger.debug(stderr); - return cb(err); - } - cb(null); - }); - }, - // Insert compiled manfiest into /tmp/appPackage.clean.apk - function(cb) { - var cleanAPKSource = path.resolve(__dirname, '../app/android/Clean.apk'); - logger.debug("Writing tmp apk. " + cleanAPKSource + ' to ' + cleanAPK); +ADB.prototype.compileManifest = function(manifest, cb) { + var androidHome = process.env.ANDROID_HOME + , platforms = androidHome + '/platforms/' + , platform = 'android-17'; - // Write clean apk to /tmp - ncp(cleanAPKSource, cleanAPK, function(err) { - if (err) return cb(err); - cb(null); - }); - }, - function(cb) { - logger.debug("Testing tmp clean apk."); - testZipArchive(cleanAPK, function(err) { - if (err) return cb(err); - cb(null); - }); - }, - function(cb) { - // -j = keep only the file, not the dirs - // -m = move manifest into target apk. - var replaceCmd = 'zip -j -m "' + cleanAPK + '" "' + manifest + '"'; - logger.debug(replaceCmd); - exec(replaceCmd, {}, function(err) { - if (err) { - return cb(err); - } - logger.debug("Inserted manifest."); - cb(null); - }); - }, - // Resign clean apk and target apk - function(cb) { - var apks = [ cleanAPK ]; - if (!skipAppSign) { - logger.debug("Signing app and clean apk."); - apks.push(targetAPK); - } else { - logger.debug("Skip app sign. Sign clean apk."); - } - me.sign(function(err) { - if (err) return cb(err); - cb(null); - }, apks); + // android-17 may be called android-4.2 + if (!fs.existsSync(platforms + platform)) { + platform = 'android-4.2'; + + if (!fs.existsSync(platforms + platform)) { + return cb(new Error("Platform doesn't exist " + platform)); + } + } + + // Compile manifest into manifest.xml.apk + var compileManifest = ['aapt package -M "', manifest, + '" -I "', platforms + platform + '/android.jar" -F "', + manifest, '.apk" -f'].join(''); + logger.debug(compileManifest); + exec(compileManifest, {}, function(err, stdout, stderr) { + if (err) { + logger.debug(stderr); + return cb("error compiling manifest"); + } + logger.debug("Compiled manifest"); + cb(null); + }); +}; + +ADB.prototype.insertManifest = function(manifest, srcApk, dstApk, cb) { + var extractManifest = function(cb) { + // Extract compiled manifest from manifest.xml.apk + unzipFile(manifest + '.apk', function(err, stderr) { + if (err) { + logger.debug(stderr); + return cb(err); } - ], - // Invoke top level function cb - function(err) { cb(err); } - ); + cb(null); + }); + }; + + var createTmpApk = function(cb) { + logger.debug("Writing tmp apk. " + srcApk + ' to ' + dstApk); + ncp(srcApk, dstApk, cb); + }; + + var testDstApk = function(cb) { + logger.debug("Testing new tmp apk."); + testZipArchive(dstApk, cb); + }; + + var moveManifest = function(cb) { + // Insert compiled manfiest into /tmp/appPackage.clean.apk + // -j = keep only the file, not the dirs + // -m = move manifest into target apk. + var replaceCmd = 'zip -j -m "' + dstApk + '" "' + manifest + '"'; + logger.debug(replaceCmd); + exec(replaceCmd, {}, function(err) { + if (err) { + return cb(err); + } + logger.debug("Inserted manifest."); + cb(null); + }); + }; + + async.series([ + function(cb) { extractManifest(cb); }, + function(cb) { createTmpApk(cb); }, + function(cb) { testDstApk(cb); }, + function(cb) { moveManifest(cb); } + ], cb); }; // apks is an array of strings. -ADB.prototype.sign = function(cb, apks) { +ADB.prototype.sign = function(apks, cb) { var signPath = path.resolve(__dirname, '../app/android/sign.jar'); var resign = 'java -jar "' + signPath + '" "' + apks.join('" "') + '" --override'; logger.debug("Resigning: " + resign); @@ -250,10 +252,8 @@ ADB.prototype.sign = function(cb, apks) { return cb(new Error("Could not sign one or more apks. Are you sure " + "the file paths are correct: " + JSON.stringify(apks))); - } else if (err) { - return cb(err); } - cb(null); + cb(err); }); }; @@ -278,6 +278,8 @@ ADB.prototype.checkFastReset = function(cb) { return cb(null); } + if (!this.appPackage) return cb(new Error("appPackage must be set.")); + var me = this; me.checkApkCert(me.cleanAPK, function(cleanSigned){ me.checkApkCert(me.apkPath, function(appSigned){ @@ -287,7 +289,7 @@ ADB.prototype.checkFastReset = function(cb) { } else { if (!appSigned) { // Resign app apk because it's not signed. - me.sign(function(err) { if (err) return cb(err); cb(null); }, [me.apkPath]); + me.sign([me.apkPath], cb); } else { // App and clean are already existing and signed. cb(null); @@ -297,66 +299,163 @@ ADB.prototype.checkFastReset = function(cb) { }); }; -ADB.prototype.start = function(onReady, onExit) { +ADB.prototype.getDeviceWithRetry = function(cb) { + var me = this; + var getDevices = function(innerCb) { + me.getConnectedDevices(function(err, devices) { + if (devices.length === 0 || err) { + return innerCb(new Error("Could not find a connected Android device.")); + } + innerCb(null); + }); + }; + getDevices(function(err) { + if (err) { + logger.info("Could not find devices, restarting adb server..."); + me.restartAdb(function() { + getDevices(cb); + }); + } else { + cb(null); + } + }); +}; + +ADB.prototype.prepareDevice = function(onReady) { + var me = this; + async.series([ + function(cb) { me.checkAdbPresent(cb); }, + function(cb) { me.getDeviceWithRetry(cb);}, + function(cb) { me.waitForDevice(cb); }, + function(cb) { me.checkFastReset(cb); } + ], onReady); +}; + +ADB.prototype.startAppium = function(onReady, onExit) { + var me = this + , doRun = function(err) { + if (err) return onReady(err); + me.runBootstrap(onReady, onExit); + }; this.onExit = onExit; - var doRun = _.bind(function() { - this.runBootstrap(onReady, onExit); - }, this); logger.debug("Using fast reset? " + this.fastReset); - var me = this; - async.series( - [ - function(cb) { - me.checkAdbPresent(function(err) { if (err) return onReady(err); cb(null); }); - }, - function(cb) { - var getDevices = function(innerCb) { - me.getConnectedDevices(function(err, devices) { - if (devices.length === 0 || err) { - innerCb("Could not find a connected Android device."); - } - innerCb(null); - }); - }; - getDevices(function(err) { - if (err) { - logger.info("restarting..."); - me.restartAdb(function() { - getDevices(cb); - }); - } else { - cb(null); - } - }); - }, - function(cb) { - me.waitForDevice(function(err) { if (err) return onReady(err); cb(null); }); - }, - function(cb) { - me.pushAppium(function(err) { if (err) return onReady(err); cb(null); }); - }, - function(cb) { - me.forwardPort(function(err) { if (err) return onReady(err); cb(null); }); - }, - function(cb) { - if (!me.appPackage) return onReady("appPackage must be set."); - me.checkFastReset(function(err) { - if (err) return onReady(err); cb(null); - }); - }, - function(cb) { - me.installApp(function(err) { if (err) return onReady(err); cb(null); }); - }, - function(cb) { - me.startApp(function(err) { - if (err) return onReady(err); - doRun(function(){ cb(null); }); - }); + async.series([ + function(cb) { me.prepareDevice(cb); }, + function(cb) { me.installApp(cb); }, + function(cb) { me.forwardPort(cb); }, + function(cb) { me.pushAppium(cb); }, + function(cb) { me.startApp(cb); } + ], doRun); +}; + +ADB.prototype.startSelendroid = function(serverPath, onReady) { + var me = this + , modServerExists = false; + this.selendroidServerPath = serverPath.replace(/\.apk$/, ".mod.apk"); + + var checkModServerExists = function(cb) { + fs.stat(me.selendroidServerPath, function(err) { + modServerExists = !err; + cb(); + }); + }; + + var conditionalUninstallSelendroid = function(cb) { + if (!modServerExists) { + // assume we're going to rebuild selendroid and therefore + // need to uninstall it if it's already on device + logger.info("Rebuilt selendroid apk does not exist, uninstalling " + + "any instances of it on device to make way for new one"); + me.uninstallApk('org.openqa.selendroid', cb); + } else { + logger.info("Rebuilt selendroid apk exists, doing nothing"); + cb(); + } + }; + + var conditionalInsertManifest = function(cb) { + if (!modServerExists) { + logger.info("Rebuilt selendroid server does not exist, inserting " + + "modified manifest"); + me.insertSelendroidManifest(serverPath, cb); + } else { + logger.info("Rebuilt selendroid server already exists, no need to " + + "rebuild it with a new manifest"); + cb(); + } + }; + + var conditionalInstallSelendroid = function(cb) { + me.checkAppInstallStatus('org.openqa.selendroid', function(e, installed) { + if (!installed) { + logger.info("Rebuilt selendroid is not installed, installing it"); + me.installApk(me.selendroidServerPath, cb); + } else { + logger.info("Rebuilt selendroid is already installed"); + cb(); } - ] - ); + }); + }; + + async.series([ + function(cb) { me.prepareDevice(cb); }, + function(cb) { checkModServerExists(cb); }, + function(cb) { conditionalUninstallSelendroid(cb); }, + function(cb) { conditionalInsertManifest(cb); }, + function(cb) { me.checkSelendroidCerts(me.selendroidServerPath, cb); }, + function(cb) { conditionalInstallSelendroid(cb); }, + function(cb) { me.installApp(cb); }, + function(cb) { me.forwardPort(cb); }, + function(cb) { me.pushSelendroid(cb); }, + function(cb) { logger.info("Selendroid server is launching"); cb(); } + ], onReady); +}; + +ADB.prototype.pushSelendroid = function(cb) { + var cmd = "adb shell am instrument -e main_activity '" + this.appPackage + + "." + this.appActivity + "' org.openqa.selendroid/" + + "org.openqa.selendroid.ServerInstrumentation"; + logger.info("Starting instrumentation process for selendroid with cmd: " + + cmd); + exec(cmd, function(err, stdout) { + if (stdout.indexOf("Exception") !== -1) { + logger.error(stdout); + var msg = stdout.split("\n")[0] || "Unknown exception starting selendroid"; + return cb(new Error(msg)); + } + cb(); + }); +}; + +ADB.prototype.checkSelendroidCerts = function(serverPath, cb) { + var me = this + , alreadyReturned = false + , checks = 0; + + var onDoneSigning = function() { + checks++; + if (checks === 2 && !alreadyReturned) { + cb(); + } + }; + + // these run in parallel + var apks = [serverPath, this.apkPath]; + _.each(apks, function(apk) { + logger.info("Checking signed status of " + apk); + me.checkApkCert(apk, function(isSigned) { + if (isSigned) return onDoneSigning(); + me.sign([apk], function(err) { + if (err && !alreadyReturned) { + alreadyReturned = true; + return cb(err); + } + onDoneSigning(); + }); + }); + }); }; ADB.prototype.getConnectedDevices = function(cb) { @@ -383,9 +482,9 @@ ADB.prototype.getConnectedDevices = function(cb) { ADB.prototype.forwardPort = function(cb) { this.requireDeviceId(); - var devicePort = 4724; - this.debug("Forwarding system:" + this.port + " to device:" + devicePort); - var arg = "tcp:" + this.port + " tcp:" + devicePort; + this.debug("Forwarding system:" + this.systemPort + " to device:" + + this.devicePort); + var arg = "tcp:" + this.systemPort + " tcp:" + this.devicePort; exec(this.adbCmd + " forward " + arg, _.bind(function(err) { if (err) { logger.error(err); @@ -445,7 +544,7 @@ ADB.prototype.checkForSocketReady = function(output) { if (/Appium Socket Server Ready/.exec(output)) { this.requirePortForwarded(); this.debug("Connecting to server on device..."); - this.socketClient = net.connect(this.port, _.bind(function() { + this.socketClient = net.connect(this.systemPort, _.bind(function() { this.debug("Connected!"); this.onSocketReady(null); }, this)); @@ -657,13 +756,13 @@ ADB.prototype.waitForDevice = function(cb) { ADB.prototype.restartAdb = function(cb) { logger.info("Killing ADB server so it will come back online"); var cmd = this.adb + " kill-server"; - exec(cmd, _.bind(function(err) { + exec(cmd, function(err) { if (err) { logger.error("Error killing ADB server, going to see if it's online " + "anyway"); } cb(); - }, this)); + }); }; ADB.prototype.pushAppium = function(cb) { @@ -850,58 +949,69 @@ ADB.prototype.runFastReset = function(cb) { }); }; +ADB.prototype.checkAppInstallStatus = function(pkg, cb) { + var installed = false + , cleanInstalled = false; + this.requireDeviceId(); + + logger.debug("Getting install/clean status for " + pkg); + var listPkgCmd = this.adbCmd + " shell pm list packages -3 " + pkg; + console.log(listPkgCmd); + exec(listPkgCmd, function(err, stdout) { + //console.log("Packages found:"); + //console.log(stdout); + var apkInstalledRgx = new RegExp('^package:' + + pkg.replace(/([^a-zA-Z])/g, "\\$1") + '$', 'm'); + installed = !!apkInstalledRgx.test(stdout); + var cleanInstalledRgx = new RegExp('^package:' + + (pkg + '.clean').replace(/([^a-zA-Z])/g, "\\$1") + '$', 'm'); + cleanInstalled = !!cleanInstalledRgx.test(stdout); + cb(null, installed, cleanInstalled); + }); +}; + ADB.prototype.installApp = function(cb) { - var me = this; - var next = function() { - me.requireDeviceId(); - me.requireApk(); - var installApp = false; - var installClean = false; - async.series( - [ - function(cb) { - var listPkgCmd = me.adbCmd + " shell pm list packages -3 " + me.appPackage; - exec(listPkgCmd, function(err, stdout) { - var apkInstalledRgx = new RegExp('^package:' + me.appPackage.replace(/([^a-zA-Z])/g, "\\$1") + '$', 'm'); - installApp = ! apkInstalledRgx.test(stdout); - var cleanInstalledRgx = new RegExp('^package:' + (me.appPackage + '.clean').replace(/([^a-zA-Z])/g, "\\$1") + '$', 'm'); - installClean = ! cleanInstalledRgx.test(stdout); - me.debug("Packages found:"); - me.debug(stdout); - cb(null); - }); - }, - function(cb) { - if (installApp) { - me.debug("Installing app apk"); - me.installApk(me.apkPath, function(err) { - if (err) return cb(err); - return cb(null); - }); - } else { cb(null); } - }, - function(cb) { - if (installClean) { - me.debug("Installing clean apk"); - me.installApk(me.cleanAPK, function(err) { if (err) return cb(err); return cb(null); }); - } else { cb(null); } - }, - function(cb) { - // App is already installed so reset it. - if (!installApp) { - me.runFastReset(function(err) { - if (err) return cb(err); - return cb(null); - }); - } else { cb(null); } - } - ], - // Top level callback. - function(err) { cb(err); } - ); + var me = this + , installApp = false + , installClean = false; + me.requireDeviceId(); + me.requireApk(); + + var determineInstallAndCleanStatus = function(cb) { + me.checkAppInstallStatus(me.appPackage, function(err, installed, cleaned) { + installApp = !installed; + installClean = !cleaned; + cb(); + }); }; - next(); + var doInstall = function(cb) { + if (installApp) { + me.debug("Installing app apk"); + me.installApk(me.apkPath, cb); + } else { cb(null); } + }; + + var doClean = function(cb) { + if (installClean && me.cleanApp) { + me.debug("Installing clean apk"); + me.installApk(me.cleanAPK, cb); + } else { cb(null); } + }; + + var doFastReset = function(cb) { + // App is already installed so reset it. + if (!installApp && me.fastReset) { + me.runFastReset(cb); + } else { cb(null); } + }; + + async.series([ + function(cb) { determineInstallAndCleanStatus(cb); }, + function(cb) { doInstall(cb); }, + function(cb) { doClean(cb); }, + function(cb) { doFastReset(cb); } + ], cb); }; ADB.prototype.back = function(cb) {