appium-instruments
Version:
IOS Instruments + instruments-without-delay launcher used by Appium
513 lines (457 loc) • 16.3 kB
JavaScript
// Wrapper around Apple's Instruments app
'use strict';
var spawn = require('child_process').spawn,
through = require('through'),
exec = require('child_process').exec,
logger = require('./logger.js'),
_ = require('underscore'),
uuid = require('uuid-js'),
path = require('path'),
rimraf = require('rimraf'),
async = require('async'),
mkdirp = require('mkdirp'),
fs = require('fs');
var ERR_NEVER_CHECKED_IN = "Instruments never checked in",
ERR_CRASHED_ON_STARTUP = "Instruments crashed on startup";
var INST_STALL_TIMEOUT = 12000;
var Instruments = function (opts) {
this.app = opts.app;
// number or object like { global: 40000, afterSimLaunch: 5000 }
// may also parse JSON strings.
if (typeof opts.launchTimeout === 'string') {
try {
opts.launchTimeout = JSON.parse(opts.launchTimeout);
} catch (err) {
logger.warn("Invalid launch timeout: " + opts.launchTimeout);
}
}
this.launchTimeout = opts.launchTimeout || 90000;
if (typeof this.launchTimeout === 'number') {
this.launchTimeout = {
global: this.launchTimeout
};
}
this.termTimeout = 3000;
this.killTimeout = 3000;
this.termTimer = null;
this.killTimer = null;
this.flakeyRetries = opts.flakeyRetries;
this.launchTries = 0;
this.neverConnected = false;
this.udid = opts.udid;
if (typeof opts.isSafariLauncherApp !== "undefined") {
logger.warn("The `isSafariLauncherApp` option is deprecated. Use the " +
"`ignoreStartupExit` option instead");
}
this.ignoreStartupExit = opts.ignoreStartupExit || opts.isSafariLauncherApp;
this.bootstrap = opts.bootstrap;
this.template = opts.template;
this.withoutDelay = opts.withoutDelay;
this.xcodeVersion = opts.xcodeVersion;
this.webSocket = opts.webSocket;
this.resultHandler = this.defaultResultHandler;
this.exitHandler = this.defaultExitHandler;
this.socketConnectTimeouts = [];
this.proc = null;
this.shutdownCb = null;
this.didLaunch = false;
this.debugMode = false;
this.guid = uuid.create();
this.instrumentsPath = "";
this.processArguments = opts.processArguments;
this.simulatorSdkAndDevice = opts.simulatorSdkAndDevice;
this.tmpDir = opts.tmpDir || '/tmp/appium-instruments';
this.traceDir = opts.traceDir || this.tmpDir;
this.gotFBSOpenApplicationError = false;
};
Instruments.killAllSim = function (xcodeVersion) {
if (xcodeVersion >= "6") {
logger.debug("Killall iOS Simulator");
exec('pkill -9 -f "iOS Simulator"');
} else {
logger.debug("Killall iPhoneSimulator");
exec('pkill -9 -f iPhoneSimulator');
}
};
Instruments.killAll = function () {
logger.debug("Killall instruments");
exec('pkill -9 -f instruments');
};
Instruments.getAvailableDevicesWithRetry = function (times, cb) {
async.retry(times, Instruments.getAvailableDevices, cb);
};
Instruments.getAvailableDevices = function (cb) {
logger.debug("Getting list of devices instruments supports");
Instruments.getInstrumentsPath(function (err, instrumentsPath) {
if (err) return cb(err);
var opts = {timeout: INST_STALL_TIMEOUT};
exec("'" + instrumentsPath + "' -s devices", opts, function (err, stdout, stderr) {
if (err) {
var msg = "Failed getting devices. Err: " + err + ". Stdout: " +
stdout + ". Stderr: " + stderr + ".";
logger.error(msg);
return cb(new Error(msg));
}
var devices = [];
_.each(stdout.split("\n"), function (line) {
if (/^i.+$/.test(line)) {
devices.push(line);
}
});
cb(null, devices);
});
});
};
Instruments.getInstrumentsPath = function (cb) {
exec('xcrun -find instruments', function (err, stdout) {
if (typeof stdout === "undefined") stdout = "";
var instrumentsPath = stdout.trim();
if (err || !instrumentsPath) {
logger.error("Could not find instruments binary");
if (err) logger.error(err.message);
return cb(new Error("Could not find the instruments " +
"binary. Please ensure `xcrun -find instruments` can locate it."));
}
logger.debug("Instruments is at: " + instrumentsPath);
cb(null, instrumentsPath);
});
};
/* INITIALIZATION */
Instruments.prototype.start = function (cb, unexpectedExitCb) {
cb = cb || function () {};
this.postLaunchHandler = cb;
unexpectedExitCb = unexpectedExitCb || function () {};
if (this.didLaunch) {
return cb(new Error("Called start() but we already launched"));
}
this.exitHandler = unexpectedExitCb;
this.setInstrumentsPath(function (err) {
if (err) {
logger.error(err.message);
return cb(err);
}
this.launch(function (err) {
if (err) return cb(err);
}.bind(this));
}.bind(this));
};
Instruments.prototype.setInstrumentsPath = function (cb) {
Instruments.getInstrumentsPath(function (err, instrumentsPath) {
if (err) return cb(err);
this.instrumentsPath = instrumentsPath;
cb();
}.bind(this));
};
Instruments.prototype.launchHandler = function (err) {
if (!this.launchHandlerCalledBack) {
_(this.socketConnectTimeouts).each(function (t) {
clearTimeout(t);
}, this);
this.socketConnectTimeouts = [];
if (!err) {
this.didLaunch = true;
this.neverConnected = false;
} else {
Instruments.killAll();
var errIsCatchable = err.message === ERR_NEVER_CHECKED_IN ||
err.message === ERR_CRASHED_ON_STARTUP;
logger.debug(err.message);
if (this.launchTries < this.flakeyRetries && errIsCatchable) {
this.launchTries++;
logger.debug("Attempting to retry launching instruments, this is " +
"retry #" + this.launchTries);
var waitForLaunch = 5000;
if (this.gotFBSOpenApplicationError) {
logger.debug("Got the FBSOpenApplicationError, not killing the " +
"sim but leaving it open so the app will launch");
waitForLaunch = 1000;
this.gotFBSOpenApplicationError = false; // clear out for next launch
} else {
Instruments.killAllSim(this.xcodeVersion);
}
// waiting a bit before restart
setTimeout(function () {
this.launch(function (err) {
if (err) return this.launchHandler(err);
}.bind(this));
}.bind(this), waitForLaunch);
} else if (errIsCatchable) {
logger.debug("We exceeded the number of retries allowed for " +
"instruments to successfully start; failing launch");
this.postLaunchHandler(err);
}
}
} else {
logger.debug("Trying to call back from instruments launch but we " +
"already did");
}
};
Instruments.prototype.termProc = function () {
if (this.proc !== null) {
logger.debug("Sending sigterm to instruments");
this.termTimer = setTimeout(function () {
logger.debug("Instruments didn't terminate after " + (this.termTimeout / 1000) +
" seconds; trying to kill it");
Instruments.killAll();
}.bind(this), this.termTimeout);
this.proc.kill('SIGTERM');
}
};
Instruments.prototype.onSocketNeverConnect = function (desc) {
return function () {
logger.warn("Instruments socket client never checked in; timing out (" + desc + ")");
this.neverConnected = true;
Instruments.killAll();
}.bind(this);
};
// launch Instruments and kill it when the function passed in as the 'condition'
// param returns true.
Instruments.prototype.launchAndKill = function (condition, cb) {
try {
rimraf.sync(this.tmpDir);
mkdirp.sync(this.tmpDir);
mkdirp.sync(this.traceDir);
} catch (err) {
return cb(err);
}
cb = _.once(cb);
var warnlevel = 10; // if we pass 10 attempts to kill but fail, log a warning
logger.info("Launching instruments briefly then killing it");
this.setInstrumentsPath(function (err) {
if (err) return cb(err);
var diedTooYoung = this.spawnInstruments();
diedTooYoung.on("error", function (err) {
if (err.message.indexOf("ENOENT") !== -1) {
cb(new Error("Unable to spawn instruments: " + err.message));
}
});
var attempts = 0;
var timelyCauseOfDeath = function () {
attempts++;
if (attempts > warnlevel && attempts < warnlevel + 3) {
logger.warn("attempted to kill instruments " + attempts + " times. Could be stuck on the wait condition.");
}
logger.debug("Checking condition to see if we should kill instruments");
if (condition()) {
logger.debug("Condition passed, killing instruments and calling back");
diedTooYoung.kill("SIGKILL");
cb();
} else {
setTimeout(timelyCauseOfDeath, 700);
}
};
process.nextTick(timelyCauseOfDeath);
}.bind(this));
};
Instruments.prototype.launch = function (cb) {
logger.info("Launching instruments");
// prepare temp dir
try {
rimraf.sync(this.tmpDir);
mkdirp.sync(this.tmpDir);
mkdirp.sync(this.traceDir);
} catch (err) {
return cb(err);
}
this.instrumentsExited = false;
this.proc = this.spawnInstruments();
this.proc.on("error", function (err) {
logger.error("Error with instruments proc: " + err.message);
if (err.message.indexOf("ENOENT") !== -1) {
this.proc = null; // otherwise we'll try to send sigkill
if (!this.instrumentsExited) {
this.instrumentsExited = true;
cb(new Error("Unable to spawn instruments: " + err.message));
}
}
}.bind(this));
// start waiting for instruments to launch successfully
this.socketConnectTimeouts.push(setTimeout(
this.onSocketNeverConnect('global'),
this.launchTimeout.global));
this.proc.stdout.setEncoding('utf8');
this.proc.stderr.setEncoding('utf8');
this.proc.stdout.pipe(through(this.outputStreamHandler.bind(this)));
this.proc.stderr.pipe(through(this.errorStreamHandler.bind(this)));
this.proc.on('exit', function (code) {
if (!this.instrumentsExited) {
this.instrumentsExited = true;
this.onInstrumentsExit(code);
}
}.bind(this));
};
Instruments.prototype.spawnInstruments = function () {
var traceDir;
for (var i = 0; ; i++) {
// loop while there are tracedirs to delete
traceDir = path.resolve(this.traceDir, 'instrumentscli' + i + '.trace');
if (!fs.existsSync(traceDir)) break;
}
var args = ["-t", this.template, "-D", traceDir];
if (this.udid) {
args = args.concat(["-w", this.udid]);
logger.debug("Attempting to run app on real device with UDID " + this.udid);
}
if (!this.udid && this.simulatorSdkAndDevice) {
args = args.concat(["-w", this.simulatorSdkAndDevice]);
logger.debug("Attempting to run app on " + this.simulatorSdkAndDevice);
}
args = args.concat([this.app]);
if (this.processArguments) {
args = args.concat(this.processArguments);
logger.debug("Attempting to run app with process arguments: " + this.processArguments);
}
args = args.concat(["-e", "UIASCRIPT", this.bootstrap]);
args = args.concat(["-e", "UIARESULTSPATH", this.tmpDir]);
var env = _.clone(process.env);
var thirdpartyPath = path.resolve(__dirname, "../thirdparty");
var xcodeVer = parseInt(this.xcodeVersion, 10);
var iwdPath;
if (xcodeVer === 4) {
iwdPath = path.resolve(thirdpartyPath, "iwd4");
} else if (xcodeVer === 5) {
iwdPath = path.resolve(thirdpartyPath, "iwd5");
} else if (xcodeVer === 6) {
iwdPath = path.resolve(thirdpartyPath, "iwd6")
} else {
iwdPath = path.resolve(thirdpartyPath, "iwd");
}
iwdPath = path.resolve(thirdpartyPath, iwdPath);
env.CA_DEBUG_TRANSACTIONS = 1;
if (this.withoutDelay && !this.udid) {
env.DYLD_INSERT_LIBRARIES = path.resolve(iwdPath, "InstrumentsShim.dylib");
env.LIB_PATH = iwdPath;
}
var logArgs = [this.instrumentsPath].concat(_.clone(args));
logArgs = _.map(logArgs, function (arg) {
if (arg.indexOf(" ") !== -1) {
return '"' + arg + '"';
}
return arg;
});
logger.debug("Spawning instruments with command: " + logArgs.join(" "));
logger.debug("And extra without-delay env: " + JSON.stringify({
DYLD_INSERT_LIBRARIES: env.DYLD_INSERT_LIBRARIES,
LIB_PATH: env.LIB_PATH
}));
logger.debug("And launch timeouts (in ms): " + JSON.stringify(this.launchTimeout));
return spawn(this.instrumentsPath, args, {env: env});
};
Instruments.prototype.onInstrumentsExit = function (code) {
if (this.termTimer) {
clearTimeout(this.termTimer);
}
if (this.killTimer) {
clearTimeout(this.killTimer);
}
this.debug("Instruments exited with code " + code);
if (this.neverConnected) {
this.neverConnected = false; // reset so we can catch this again
return this.launchHandler(new Error(ERR_NEVER_CHECKED_IN));
}
if (!this.didLaunch && !this.ignoreStartupExit) {
return this.launchHandler(new Error(ERR_CRASHED_ON_STARTUP));
}
this.cleanupInstruments();
if (this.ignoreStartupExit) {
logger.debug("Not worrying about instruments exit since we're using " +
"SafariLauncher");
this.postLaunchHandler();
} else if (this.shutdownCb !== null) {
this.shutdownCb();
this.shutdownCb = null;
} else {
this.exitHandler(code, this.traceDir);
}
};
Instruments.prototype.cleanupInstruments = function () {
logger.debug("Cleaning up after instruments exit");
this.proc = null;
};
/* PROCESS MANAGEMENT */
Instruments.prototype.shutdown = function (cb) {
var wasShutDown = false;
var shutdownTimeout;
function wrap(err) {
wasShutDown = true;
clearTimeout(shutdownTimeout);
cb(err);
}
shutdownTimeout = setTimeout(function () {
if (!wasShutDown) {
cb("Didn't not shutdown within 5 seconds, maybe process did not start or was already dead.");
}
}, 5000);
this.shutdownCb = wrap;
this.termProc();
};
Instruments.prototype.doExit = function () {
logger.info("Calling exit handler");
};
/* INSTRUMENTS STREAM MANIPULATION*/
Instruments.prototype.clearBufferChars = function (output) {
// Instruments output is buffered, so for each log output we also output
// a stream of very many ****. This function strips those out so all we
// get is the log output we care about
var re = /(\n|^)\*+\n?/g;
output = output.toString();
output = output.replace(re, "");
return output;
};
Instruments.prototype.outputStreamHandler = function (output) {
output = this.clearBufferChars(output);
this.resultHandler(output);
};
Instruments.prototype.errorStreamHandler = function (output) {
if (this.launchTimeout.afterSimLaunch && output && output.match(/CLTilesManagerClient: initialize/)) {
this.socketConnectTimeouts.push(setTimeout(
this.onSocketNeverConnect('afterLaunch'),
this.launchTimeout.afterSimLaunch));
}
var fbsErrStr = "(FBSOpenApplicationErrorDomain error 8.)";
if (output.indexOf(fbsErrStr) !== -1) {
this.gotFBSOpenApplicationError = true;
}
output = output.replace(/\n$/m, "");
var logMsg = ("[INST STDERR] " + output);
logMsg = logMsg.yellow;
logger.debug(logMsg);
if (this.webSocket) {
var re = /Call to onAlert returned 'YES'/;
var match = re.test(output);
if (match) {
logger.debug("Emiting alert message...");
this.webSocket.sockets.emit('alert', {message: output});
}
}
};
/* DEFAULT HANDLERS */
Instruments.prototype.setResultHandler = function (handler) {
this.resultHandler = handler;
};
Instruments.prototype.defaultResultHandler = function (output) {
// if we have multiple log lines, indent non-first ones
if (output !== "") {
output = output.replace(/\n$/m, "");
output = output.replace(/\n/m, "\n ");
output = "[INST] " + output;
output = output.green;
logger.debug(output);
}
};
Instruments.prototype.defaultExitHandler = function (code, traceDir) {
logger.debug("Instruments exited with code " + code + " and trace dir " + traceDir);
};
/* MISC */
Instruments.prototype.setDebug = function (debug) {
if (typeof debug === "undefined") {
debug = true;
}
this.debugMode = debug;
};
Instruments.prototype.debug = function (msg) {
var log = "[INSTSERVER] " + msg;
log = log.grey;
logger.debug(log);
};
module.exports = Instruments;