@qunitjs/browserstack-runner
Version:
[Hotfix] run browser tests over BrowserStack
564 lines (490 loc) • 18.8 kB
JavaScript
var Log = require('./logger'),
logger = new Log(global.logLevel || 'info'),
http = require('http'),
url = require('url'),
path = require('path'),
util = require('util'),
fs = require('fs'),
qs = require('querystring'),
utils = require('./utils'),
proxyServer = require('./proxy').proxyServer,
chalk = require('chalk'),
mime = require('mime'),
send = require('send'),
vm = require('vm'),
CircularJSON = require('circular-json'),
resolve = require('resolve'),
zlib = require('zlib');
exports.Server = function Server(bsClient, workers, config, callback) {
var testFilePaths = (Array.isArray(config.test_path) ? config.test_path : [ config.test_path ])
.map(function (path) {
return path.split(/[?#]/)[0];
}),
reports = [];
function getBrowserReport(browserInfo) {
var browserReport = null;
reports.forEach(function(report) {
if(report && report.browser === browserInfo) {
browserReport = report;
}
});
if(!browserReport) {
browserReport = {
browser: browserInfo
};
reports.push(browserReport);
}
browserReport.tests = browserReport.tests || [];
return browserReport;
}
function reformatJasmineReport(browserReport) {
var results = browserReport.suites;
browserReport.tests = browserReport.tests || [ ];
browserReport.suites = {
fullName : [ ],
childSuites : [ ],
tests : [ ],
status : !results.failed ? 'passed' : 'failed',
testCounts : {
passed : results.passed,
failed : results.failed,
skipped : results.skipped,
total : results.total,
},
runtime : results.runtime
};
function recurseThroughSuites(jasmineSuite, par) {
if(!jasmineSuite) {
return;
}
var suite = {
name : jasmineSuite.description,
fullName: [ ],
childSuites : [ ],
tests: [ ],
status : jasmineSuite.passed ? 'passed' : 'failed',
testCounts : {
passed : 0,
failed : 0,
skipped: 0,
total: 0
},
runtime: 0
};
if(par.name) {
suite.fullName.push(par.name);
}
suite.fullName.push(jasmineSuite.description);
jasmineSuite.specs.forEach(function(spec) {
var test = {
name : spec.description,
suiteName : suite.decription,
fullName : [
],
status : spec.passed ? 'passed' : (spec.results.skipped ? 'skipped' : 'failed'),
runtime : spec.durationSec,
errors : [ ],
assertions : [ ]
};
Array.prototype.push.apply(test.fullName, suite.fullName);
test.fullName.push(spec.description);
if(!spec.passed) {
spec.results.items_.forEach(function(jasmineItem) {
if(!jasmineItem.passed_) {
var detail = {
passed : false
};
if('message' in jasmineItem) {
detail.message = jasmineItem.message;
}
if('actual' in jasmineItem) {
detail.actual = jasmineItem.actual;
}
if('expected' in jasmineItem) {
detail.expected = jasmineItem.expected;
}
if('trace' in jasmineItem) {
detail.stack = jasmineItem.trace.message || jasmineItem.trace.stack;
}
test.errors.push(detail);
test.assertions.push(detail);
}
});
}
suite.tests.push(test);
browserReport.tests.push(test);
if(spec.passed) {
++suite.testCounts.passed;
}
else if(spec.skipped) {
++suite.testCounts.skipped;
}
else {
++suite.testCounts.failed;
}
++suite.testCounts.total;
suite.runtime += spec.durationSec;
});
jasmineSuite.suites.forEach(function(childSuite) {
recurseThroughSuites(childSuite, suite);
});
par.childSuites.push(suite);
}
results.report.suites.forEach(function(jasmineSuite) {
recurseThroughSuites(jasmineSuite, browserReport.suites);
});
}
function handleFile(filename, request, response, doNotUseProxy) {
var url_parts = url.parse(request.url, true);
var query = url_parts.query;
if (query._worker_key && workers[query._worker_key]) {
var worker = workers[query._worker_key];
worker.markAckd();
}
var getReporterPatch = function () {
var scripts = [
'json2.js',
'browserstack.js',
'browserstack-util.js'
];
var framework_scripts = {
'jasmine': ['jasmine-jsreporter.js', 'jasmine-plugin.js']
};
var filePath = path.relative(process.cwd(), filename);
var pathMatches = (testFilePaths.indexOf(filePath) !== -1);
if (pathMatches) {
var framework = config['test_framework'];
var tag_name = (framework === 'mocha') ? 'head' : 'body';
var patch = '$1';
scripts.forEach(function(script) {
patch += '<script type="text/javascript" src="/_patch/' + script + '"></script>\n';
});
patch += externalScript('js-reporters/dist/js-reporters.js');
patch += externalScript('circular-json/build/circular-json.js');
// adding framework scripts
if (framework === 'jasmine') {
framework_scripts['jasmine'].forEach(function(script) {
patch += '<script type="text/javascript" src="/_patch/' + script + '"></script>\n';
});
patch += '<script type="text/javascript">jasmine.getEnv().addReporter(new jasmine.JSReporter());</script>\n';
} else {
patch += '<script type="text/javascript" src="/_patch/reporter.js"></script>\n';
}
patch += '</' + tag_name + '>';
return patch;
}
};
var getTestingFrameworkMatcher = function() {
var tag_name = (config['test_framework'] === 'mocha') ? 'head' : 'body';
return new RegExp('(.*)<\/' + tag_name + '>'); ///(.*)<\/body>/;
};
var writeResponse = function(err, data) {
if (err) {
sendError(response, err, 500);
return;
}
response.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8'
});
var matcher = getTestingFrameworkMatcher();
var patch = getReporterPatch();
data = data.replace(matcher, patch);
response.write(data);
response.end();
};
var patchResponse = function (data, headers, callback) {
var mimeType = mime.lookup(filename);
var finalData = data;
if (mimeType === 'text/html') {
var matcher = getTestingFrameworkMatcher();
var patch = getReporterPatch();
finalData = data.replace(matcher, patch);
headers['content-length'] = finalData.length;
}
callback && callback(finalData, headers);
};
var checkForEncodingAndPatch = function (responseData, headers, callback) {
var encoding = headers['content-encoding'];
if (encoding === 'gzip') {
zlib.gunzip(responseData, function (err, decoded) {
if (!err) {
patchResponse(decoded && decoded.toString(), headers, function (data, headers) {
zlib.gzip(data, function (err, encoded) {
if (!err) {
callback && callback(encoded, headers);
}
});
});
}
});
} else if (encoding === 'deflate') {
zlib.inflate(responseData, function (err, decoded) {
if (!err) {
patchResponse(decoded && decoded.toString(), headers, function (data, headers) {
zlib.deflate(data, function (err, encoded) {
if (!err) {
callback && callback(encoded, headers);
}
});
});
}
});
} else {
patchResponse(responseData && responseData.toString(), headers, function (data, headers) {
callback && callback(data, headers);
});
}
};
if (!doNotUseProxy && config.test_server) {
proxyServer.onRequest(request, response, config.test_server, function (remote_response, response_data) {
var headers = remote_response.headers;
checkForEncodingAndPatch(response_data, headers, function (data, headers) {
response.writeHead(remote_response.statusCode, headers);
response.write(data);
response.end();
});
});
} else {
fs.exists(filename, function(exists) {
if (!exists) {
sendError(response,'file not found', 404);
return;
}
if (fs.lstatSync(filename).isDirectory()) {
filename = filename + (filename.lastIndexOf('/') === filename.length - 1 ? '' : '/') + 'index.html';
}
var mimeType = mime.lookup(filename);
if (mimeType === 'text/html') {
fs.readFile(filename, { encoding: 'utf8' }, function (err, data) {
writeResponse(err, data);
});
} else {
send(request, filename)
.on('error', function onSendError(err) {
sendError(response, (err.message || 'Internal Server Error'), err.status || 500);
})
.pipe(response);
}
});
}
}
function formatTraceback(details) {
var output = '"' + details.testName + '" failed';
if(details.error) {
if (details.error.message) {
output += ', ' + details.error.message;
}
if (details.error.actual != null && details.error.expected != null) {
output += '\n' + chalk.blue('Expected: ') + details.error.expected +
'\n' + chalk.blue(' Actual: ') + details.error.actual;
}
if (details.error.source || details.error.stack) {
output += '\n' + chalk.blue(' Source: ') + '';
output += ( details.error.source || details.error.stack ).split('\n').join('\n\t ');
}
}
return output;
}
function checkAndTerminateWorker(worker, callback) {
var next_path = getNextTestPath(worker);
if (next_path) {
var url = worker.buildUrl(next_path);
worker.test_path = next_path;
worker.config.url = next_path;
bsClient.changeUrl(worker.id, { url: url }, function () {
callback(true);
});
} else {
bsClient.terminateWorker(worker.id, function () {
callback(false);
});
}
}
function getNextTestPath(worker) {
if (!config.multipleTest) {
return null;
}
return config.test_path[ ++worker.path_index ];
}
function getWorkerUuid(request) {
var uuid = request.headers['x-worker-uuid'];
uuid = uuid && uuid.replace(/[^a-zA-Z0-9\-]/, '');
logger.trace('cleaning up worker uuid:', uuid);
uuid = (uuid && typeof workers[uuid] === 'object') ? uuid : null;
logger.trace('worker uuid', uuid, (uuid ? 'valid' : 'invalid'));
return (uuid && workers[uuid]) ? uuid : null;
}
function sendError(response, errMessage, statusCode) {
response.writeHead(statusCode || 400, {
'Content-Type': 'text/plain'
});
if (errMessage) {
response.write(errMessage + '\n');
}
response.end();
}
function externalScript(scriptPath) {
var resolvedPath = resolve.sync(scriptPath, { basedir: __dirname });
var scriptContents = fs.readFileSync(resolvedPath, { encoding: 'utf8' });
return '<script type="text/javascript">' + scriptContents + '</script>';
}
var handlers = {
'_progress': function progressHandler(uri, body, request, response) {
var uuid = getWorkerUuid(request);
if (!uuid) {
sendError(response, 'worker not found', 404);
return;
}
var worker = workers[uuid];
var browserInfo = worker.getTestBrowserInfo();
var query = null;
try {
query = CircularJSON.parse(body);
} catch(e) {
logger.info('[%s] Exception in parsing log', worker.string);
logger.info('[%s] Log: ' + qs.parse(body).data, worker.string);
}
logger.trace('[%s] _progress', worker.id, CircularJSON.stringify(query));
if (query && query.test) {
// Include all tests in the report
var browserReport = getBrowserReport(browserInfo);
browserReport.tests.push(query.test || {});
// Only print errors of failed tests
// (errors from todo tests are expected and will not be reported as
// "failed" to the "_report" handler either, so printing them here
// would create a confusing output with error printed but a success
// status at the end)
// https://github.com/browserstack/browserstack-runner/issues/247
if (query.test.errors && query.test.status !== 'todo') {
query.test.errors.forEach(function(error) {
logger.info('[%s] ' + chalk.red('Error:'), browserInfo, formatTraceback({
testName: query.test.name,
suiteName: query.test.suiteName,
status: query.test.status,
error: error
}));
});
}
}
response.end();
},
'_report': function reportHandler(uri, body, request, response) {
var uuid = getWorkerUuid(request);
if (!uuid) {
sendError(response, 'worker not found', 404);
return;
}
var worker = workers[uuid];
worker._worker_key = uuid;
var browserInfo = worker.getTestBrowserInfo();
var query = null;
try {
query = CircularJSON.parse(body);
} catch (e) {}
logger.trace('[%s] _report', worker.id, CircularJSON.stringify(query));
if (query === null) {
logger.info('[%s] Null response from remote Browser', request.headers['x-browser-string']);
} else {
var browserReport = getBrowserReport(browserInfo);
browserReport.suites = query;
var color;
if(config['test_framework'] === 'jasmine') {
color = ( query.total !== query.passed ) ? 'red' : 'green';
logger.info('[%s] ' + chalk[color](( query.total !== query.passed ) ? 'Failed:' : 'Passed:') + ' %d tests, %d passed, %d failed; ran for %dms', browserInfo, query.total, query.passed, query.failed, query.runtime);
config.status += query.failed;
reformatJasmineReport(browserReport);
} else if(query.testCounts) {
color = query.status === 'failed' ? 'red' : 'green';
logger.info('[%s] ' + chalk[color](query.status === 'failed' ? 'Failed:' : 'Passed:') + ' %d tests, %d passed, %d failed, %d skipped; ran for %dms', browserInfo, query.testCounts.total, query.testCounts.passed, query.testCounts.failed, query.testCounts.skipped, query.runtime);
config.status += query.testCounts.failed;
}
}
logger.trace('[%s] _report: client.takeScreenshot', worker.id);
bsClient.takeScreenshot(worker.id, function(error, screenshot) {
logger.trace('[%s] _report: client.takeScreenshot | response:', worker.id, screenshot, error);
if (!error && screenshot.url && query && query.failed) {
logger.info('[%s] ' + chalk.yellow('Screenshot:') + ' %s', browserInfo, screenshot.url);
}
checkAndTerminateWorker(worker, function(reusedWorker) {
if (!workers[uuid]) {
logger.trace('[%s] _report: checkAndTerminateWorker: worker not found', worker.id);
return;
}
if (reusedWorker) {
logger.trace('[%s] _report: checkAndTerminateWorker: reused worker', worker.id);
logger.debug('[%s] Reused', browserInfo);
worker.resetAck();
worker.awaitAck();
return;
}
logger.trace('[%s] _report: checkAndTerminateWorker: terminated', worker.id);
logger.debug('[%s] Terminated', browserInfo);
clearTimeout(workers[uuid].ackTimeout);
clearTimeout(workers[uuid].activityTimeout);
clearTimeout(workers[uuid].testActivityTimeout);
delete workers[uuid];
if (utils.objectSize(workers) === 0) {
var color = config.status > 0 ? 'red' : 'green';
logger.info(chalk[color]('All tests done, failures: %d.'), config.status);
if (config.status > 0) {
config.status = 1;
}
logger.trace('[%s] _report: checkAndTerminateWorker: all tests done', worker.id, config.status && 'with failures');
var testsFailedError = utils.createTestsFailedError(config);
callback(testsFailedError, reports);
}
});
});
response.end();
},
'_log': function logHandler(uri, body, request, response) {
var uuid = getWorkerUuid(request);
var query = null;
try {
query = CircularJSON.parse(body);
} catch (e) {
query = body;
}
logger.trace('[%s] _log', ((uuid && workers[uuid]) || {}).id, query);
var logged = false;
if (query && Array.isArray(query.arguments)) {
var context = { input: query.arguments, format: util.format, output: '' };
var tryEvalOrString = 'function (arg) { try { return eval(\'o = \' + arg); } catch (e) { return arg; } }';
try {
// eval each element of query.arguments safely in an isolated context
vm.runInNewContext('output = format.apply(null, input.map(' + tryEvalOrString + '));', context);
logger.info('[' + request.headers['x-browser-string'] + '] ' + context.output);
logged = true;
} catch (e) {
logger.debug('_log: failed to format console log data', query);
}
}
if (!logged) {
logger.info('[' + request.headers['x-browser-string'] + '] ' + query);
}
response.end();
},
'_patch': function patchHandler(uri, body, request, response) {
var filePath = path.join(__dirname, uri);
logger.trace('_patch', filePath);
handleFile(filePath, request, response, true);
},
'_default': function defaultHandler(uri, body, request, response) {
var filePath = path.join(process.cwd(), uri);
logger.trace('_default', filePath);
handleFile(filePath, request, response);
}
};
return http.createServer(function(request, response) {
var uri = url.parse(request.url).pathname;
var method = uri.split('/')[1];
var body = '';
request.on('data', function(data) {
body += data;
});
request.on('end', function() {
(handlers[method] || handlers._default)(uri, body, request, response);
});
});
};
exports.logger = logger;