blob: 0923e54028ed9dd4e5b3088f84de836c35fbf30a [file] [log] [blame]
// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
function $(id) {
return document.getElementById(id);
}
function createNaClEmbed(args) {
var fallback = function(value, default_value) {
return value !== undefined ? value : default_value;
};
var embed = document.createElement('embed');
embed.id = args.id;
embed.src = args.src;
embed.type = fallback(args.type, 'application/x-nacl');
// JavaScript inconsistency: this is equivalent to class=... in HTML.
embed.className = fallback(args.className, 'naclModule');
embed.width = fallback(args.width, 0);
embed.height = fallback(args.height, 0);
return embed;
}
function decodeURIArgs(encoded) {
var args = {};
if (encoded.length > 0) {
var pairs = encoded.replace(/\+/g, ' ').split('&');
for (var p = 0; p < pairs.length; p++) {
var pair = pairs[p].split('=');
if (pair.length != 2) {
throw "Malformed argument key/value pair: '" + pairs[p] + "'";
}
args[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]);
}
}
return args;
}
function addDefaultsToArgs(defaults, args) {
for (var key in defaults) {
if (!(key in args)) {
args[key] = defaults[key];
}
}
}
// Return a dictionary of arguments for the test. These arguments are passed
// in the query string of the main page's URL. Any time this function is used,
// default values should be provided for every argument. In some cases a test
// may be run without an expected query string (manual testing, for example.)
// Careful: all the keys and values in the dictionary are strings. You will
// need to manually parse any non-string values you wish to use.
function getTestArguments(defaults) {
var encoded = window.location.search.substring(1);
var args = decodeURIArgs(encoded);
if (defaults !== undefined) {
addDefaultsToArgs(defaults, args);
}
return args;
}
function exceptionToLogText(e) {
if (typeof e == 'object' && 'message' in e && 'stack' in e) {
return e.message + '\n' + e.stack.toString();
} else if (typeof(e) == 'string') {
return e;
} else {
return toString(e)
}
}
// Logs test results to the server using URL-encoded RPC.
// Also logs the same test results locally into the DOM.
function RPCWrapper() {
// Work around how JS binds 'this'
var this_ = this;
// It is assumed RPC will work unless proven otherwise.
this.rpc_available = true;
// Set to true if any test fails.
this.ever_failed = false;
// Async calls can make it faster, but it can also change order of events.
this.async = false;
// Called if URL-encoded RPC gets a 404, can't find the server, etc.
function handleRPCFailure(name, message) {
// This isn't treated as a testing error - the test can be run without a
// web server that understands RPC.
this_.logLocal('RPC failure for ' + name + ': ' + message + ' - If you ' +
'are running this test manually, this is not a problem.',
'gray');
this_.disableRPC();
}
function handleRPCResponse(name, req) {
if (req.status == 200) {
if (req.responseText == 'Die, please') {
// TODO(eugenis): this does not end the browser process on Mac.
window.close();
} else if (req.responseText != 'OK') {
this_.logLocal('Unexpected RPC response to ' + name + ': \'' +
req.responseText + '\' - If you are running this test ' +
'manually, this is not a problem.', 'gray');
this_.disableRPC();
}
} else {
handleRPCFailure(name, req.status.toString());
}
}
// Performs a URL-encoded RPC call, given a function name and a dictionary
// (actually just an object - it's a JS idiom) of parameters.
function rpcCall(name, params) {
if (window.domAutomationController !== undefined) {
// Running as a Chrome browser_test.
var msg = {type: name};
for (var pname in params) {
msg[pname] = params[pname];
}
domAutomationController.send(JSON.stringify(msg));
} else if (this_.rpc_available) {
// Construct the URL for the RPC request.
var args = [];
for (var pname in params) {
pvalue = params[pname];
args.push(encodeURIComponent(pname) + '=' + encodeURIComponent(pvalue));
}
var url = '/TESTER/' + name + '?' + args.join('&');
var req = new XMLHttpRequest();
// Async result handler
if (this_.async) {
req.onreadystatechange = function() {
if (req.readyState == XMLHttpRequest.DONE) {
handleRPCResponse(name, req);
}
}
}
try {
req.open('GET', url, this_.async);
req.send();
if (!this_.async) {
handleRPCResponse(name, req);
}
} catch (err) {
handleRPCFailure(name, err.toString());
}
}
}
// Pretty prints an error into the DOM.
this.logLocalError = function(message) {
this.logLocal(message, 'red');
this.visualError();
}
// If RPC isn't working, disable it to stop error message spam.
this.disableRPC = function() {
if (this.rpc_available) {
this.rpc_available = false;
this.logLocal('Disabling RPC', 'gray');
}
}
this.startup = function() {
// TODO(ncbray) move into test runner
this.num_passed = 0;
this.num_failed = 0;
this.num_errors = 0;
this._log('[STARTUP]');
}
this.shutdown = function() {
if (this.num_passed == 0 && this.num_failed == 0 && this.num_errors == 0) {
this.client_error('No tests were run. This may be a bug.');
}
var full_message = '[SHUTDOWN] ';
full_message += this.num_passed + ' passed';
full_message += ', ' + this.num_failed + ' failed';
full_message += ', ' + this.num_errors + ' errors';
this.logLocal(full_message);
rpcCall('Shutdown', {message: full_message, passed: !this.ever_failed});
if (this.ever_failed) {
this.localOutput.style.border = '2px solid #FF0000';
} else {
this.localOutput.style.border = '2px solid #00FF00';
}
}
this.ping = function() {
rpcCall('Ping', {});
}
this.heartbeat = function() {
rpcCall('JavaScriptIsAlive', {});
}
this.client_error = function(message) {
this.num_errors += 1;
this.visualError();
var full_message = '\n[CLIENT_ERROR] ' + exceptionToLogText(message)
// The client error could have been generated by logging - be careful.
try {
this._log(full_message, 'red');
} catch (err) {
// There's not much that can be done, at this point.
}
}
this.begin = function(test_name) {
var full_message = '[' + test_name + ' BEGIN]'
this._log(full_message, 'blue');
}
this._log = function(message, color, from_completed_test) {
if (typeof(message) != 'string') {
message = toString(message);
}
// For event-driven tests, output may come after the test has finished.
// Display this in a special way to assist debugging.
if (from_completed_test) {
color = 'orange';
message = 'completed test: ' + message;
}
this.logLocal(message, color);
rpcCall('TestLog', {message: message});
}
this.log = function(test_name, message, from_completed_test) {
if (message == undefined) {
// This is a log message that is not associated with a test.
// What we though was the test name is actually the message.
this._log(test_name);
} else {
if (typeof(message) != 'string') {
message = toString(message);
}
var full_message = '[' + test_name + ' LOG] ' + message;
this._log(full_message, 'black', from_completed_test);
}
}
this.fail = function(test_name, message, from_completed_test) {
this.num_failed += 1;
this.visualError();
var full_message = '[' + test_name + ' FAIL] ' + message
this._log(full_message, 'red', from_completed_test);
}
this.exception = function(test_name, err, from_completed_test) {
this.num_errors += 1;
this.visualError();
var message = exceptionToLogText(err);
var full_message = '[' + test_name + ' EXCEPTION] ' + message;
this._log(full_message, 'purple', from_completed_test);
}
this.pass = function(test_name, from_completed_test) {
this.num_passed += 1;
var full_message = '[' + test_name + ' PASS]';
this._log(full_message, 'green', from_completed_test);
}
this.blankLine = function() {
this._log('');
}
// Allows users to log time data that will be parsed and re-logged
// for chrome perf-bot graphs / performance regression testing.
// See: native_client/tools/process_perf_output.py
this.logTimeData = function(event, timeMS) {
this.log('NaClPerf [' + event + '] ' + timeMS + ' millisecs');
}
this.visualError = function() {
// Changing the color is defered until testing is done
this.ever_failed = true;
}
this.logLineLocal = function(text, color) {
text = text.replace(/\s+$/, '');
if (text == '') {
this.localOutput.appendChild(document.createElement('br'));
} else {
var mNode = document.createTextNode(text);
var div = document.createElement('div');
// Preserve whitespace formatting.
div.style['white-space'] = 'pre';
if (color != undefined) {
div.style.color = color;
}
div.appendChild(mNode);
this.localOutput.appendChild(div);
}
}
this.logLocal = function(message, color) {
var lines = message.split('\n');
for (var i = 0; i < lines.length; i++) {
this.logLineLocal(lines[i], color);
}
}
// Create a place in the page to output test results
this.localOutput = document.createElement('div');
this.localOutput.id = 'testresults';
this.localOutput.style.border = '2px solid #0000FF';
this.localOutput.style.padding = '10px';
document.body.appendChild(this.localOutput);
}
//
// BEGIN functions for testing
//
function fail(message, info, test_status) {
var parts = [];
if (message != undefined) {
parts.push(message);
}
if (info != undefined) {
parts.push('(' + info + ')');
}
var full_message = parts.join(' ');
if (test_status !== undefined) {
// New-style test
test_status.fail(full_message);
} else {
// Old-style test
throw {type: 'test_fail', message: full_message};
}
}
function assert(condition, message, test_status) {
if (!condition) {
fail(message, toString(condition), test_status);
}
}
// This is accepted best practice for checking if an object is an array.
function isArray(obj) {
return Object.prototype.toString.call(obj) === '[object Array]';
}
function toString(obj) {
if (typeof(obj) == 'string') {
return '\'' + obj + '\'';
}
try {
return obj.toString();
} catch (err) {
try {
// Arrays should do this automatically, but there is a known bug where
// NaCl gets array types wrong. .toString will fail on these objects.
return obj.join(',');
} catch (err) {
if (obj == undefined) {
return 'undefined';
} else {
// There is no way to create a textual representation of this object.
return '[UNPRINTABLE]';
}
}
}
}
// Old-style, but new-style tests use it indirectly.
// (The use of the "test" parameter indicates a new-style test. This is a
// temporary hack to avoid code duplication.)
function assertEqual(a, b, message, test_status) {
if (isArray(a) && isArray(b)) {
assertArraysEqual(a, b, message, test_status);
} else if (a !== b) {
fail(message, toString(a) + ' != ' + toString(b), test_status);
}
}
// Old-style, but new-style tests use it indirectly.
// (The use of the "test" parameter indicates a new-style test. This is a
// temporary hack to avoid code duplication.)
function assertArraysEqual(a, b, message, test_status) {
var dofail = function() {
fail(message, toString(a) + ' != ' + toString(b), test_status);
}
if (a.length != b.length) {
dofail();
}
for (var i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
dofail();
}
}
}
function assertRegexMatches(str, re, message, test_status) {
if (!str.match(re)) {
fail(message, toString(str) + ' doesn\'t match ' + toString(re.toString()),
test_status);
}
}
// Ideally there'd be some way to identify what exception was thrown, but JS
// exceptions are fairly ad-hoc.
// TODO(ncbray) allow manual validation of exception types?
function assertRaises(func, message, test_status) {
try {
func();
} catch (err) {
return;
}
fail(message, 'did not raise', test_status);
}
//
// END functions for testing
//
function haltAsyncTest() {
throw {type: 'test_halt'};
}
function begins_with(s, prefix) {
if (s.length >= prefix.length) {
return s.substr(0, prefix.length) == prefix;
} else {
return false;
}
}
function ends_with(s, suffix) {
if (s.length >= suffix.length) {
return s.substr(s.length - suffix.length, suffix.length) == suffix;
} else {
return false;
}
}
function embed_name(embed) {
if (embed.name != undefined) {
if (embed.id != undefined) {
return embed.name + ' / ' + embed.id;
} else {
return embed.name;
}
} else if (embed.id != undefined) {
return embed.id;
} else {
return '[no name]';
}
}
// Write data to the filesystem. This will only work if the browser_tester was
// initialized with --output_dir.
function outputFile(name, data, onload, onerror) {
var xhr = new XMLHttpRequest();
xhr.onload = onload;
xhr.onerror = onerror;
xhr.open('POST', name, true);
xhr.send(data);
}
// Webkit Bug Workaround
// THIS SHOULD BE REMOVED WHEN Webkit IS FIXED
// http://code.google.com/p/nativeclient/issues/detail?id=2428
// http://code.google.com/p/chromium/issues/detail?id=103588
function ForcePluginLoadOnTimeout(elem, tester, timeout) {
tester.log('Registering ForcePluginLoadOnTimeout ' +
'(Bugs: NaCl 2428, Chrome 103588)');
var started_loading = elem.readyState !== undefined;
// Remember that the plugin started loading - it may be unloaded by the time
// the callback fires.
elem.addEventListener('load', function() {
started_loading = true;
}, true);
// Check that the plugin has at least started to load after "timeout" seconds,
// otherwise reload the page.
setTimeout(function() {
if (!started_loading) {
ForceNaClPluginReload(elem, tester);
}
}, timeout);
}
function ForceNaClPluginReload(elem, tester) {
if (elem.readyState === undefined) {
tester.log('WARNING: WebKit plugin-not-loading error detected; reloading.');
window.location.reload();
}
}
function NaClWaiter(body_element) {
// Work around how JS binds 'this'
var this_ = this;
var embedsToWaitFor = [];
// embedsLoaded contains list of embeds that have dispatched the
// 'loadend' progress event.
this.embedsLoaded = [];
this.is_loaded = function(embed) {
for (var i = 0; i < this_.embedsLoaded.length; ++i) {
if (this_.embedsLoaded[i] === embed) {
return true;
}
}
return (embed.readyState == 4) && !this_.has_errored(embed);
}
this.has_errored = function(embed) {
var msg = embed.lastError;
return embed.lastError != undefined && embed.lastError != '';
}
// If an argument was passed, it is the body element for registering
// event listeners for the 'loadend' event type.
if (body_element != undefined) {
var eventListener = function(e) {
if (e.type == 'loadend') {
this_.embedsLoaded.push(e.target);
}
}
body_element.addEventListener('loadend', eventListener, true);
}
// Takes an arbitrary number of arguments.
this.waitFor = function() {
for (var i = 0; i< arguments.length; i++) {
embedsToWaitFor.push(arguments[i]);
}
}
this.run = function(doneCallback, pingCallback) {
this.doneCallback = doneCallback;
this.pingCallback = pingCallback;
// Wait for up to forty seconds for the nexes to load.
// TODO(ncbray) use error handling mechanisms (when they are implemented)
// rather than a timeout.
this.totalWait = 0;
this.maxTotalWait = 40000;
this.retryWait = 10;
this.waitForPlugins();
}
this.waitForPlugins = function() {
var errored = [];
var loaded = [];
var waiting = [];
for (var i = 0; i < embedsToWaitFor.length; i++) {
try {
var e = embedsToWaitFor[i];
if (this.has_errored(e)) {
errored.push(e);
} else if (this.is_loaded(e)) {
loaded.push(e);
} else {
waiting.push(e);
}
} catch(err) {
// If the module is badly horked, touching lastError, etc, may except.
errored.push(err);
}
}
this.totalWait += this.retryWait;
if (waiting.length == 0) {
this.doneCallback(loaded, errored);
} else if (this.totalWait >= this.maxTotalWait) {
// Timeouts are considered errors.
this.doneCallback(loaded, errored.concat(waiting));
} else {
setTimeout(function() { this_.waitForPlugins(); }, this.retryWait);
// Capped exponential backoff
this.retryWait += this.retryWait/2;
// Paranoid: does setTimeout like floating point numbers?
this.retryWait = Math.round(this.retryWait);
if (this.retryWait > 100)
this.retryWait = 100;
// Prevent the server from thinking the test has died.
if (this.pingCallback)
this.pingCallback();
}
}
}
function logLoadStatus(rpc, load_errors_are_test_errors,
exit_cleanly_is_an_error, loaded, waiting) {
for (var i = 0; i < loaded.length; i++) {
rpc.log(embed_name(loaded[i]) + ' loaded');
}
// Be careful when interacting with horked nexes.
var getCarefully = function (callback) {
try {
return callback();
} catch (err) {
return '<exception>';
}
}
var errored = false;
for (var j = 0; j < waiting.length; j++) {
// Workaround for WebKit layout bug that caused the NaCl plugin to not
// load. If we see that the plugin is not loaded after a timeout, we
// forcibly reload the page, thereby triggering layout. Re-running
// layout should make WebKit instantiate the plugin. NB: this could
// make the JavaScript-based code go into an infinite loop if the
// WebKit bug becomes deterministic or the NaCl plugin fails after
// loading, but the browser_tester.py code will timeout the test.
//
// http://code.google.com/p/nativeclient/issues/detail?id=2428
//
if (waiting[j].readyState == undefined) {
// alert('Woot'); // -- for manual debugging
rpc.log('WARNING: WebKit plugin-not-loading error detected; reloading.');
window.location.reload();
throw "reload NOW";
}
var name = getCarefully(function(){
return embed_name(waiting[j]);
});
var ready = getCarefully(function(){
var readyStateString =
['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE'];
// An undefined index value will return and undefined result.
return readyStateString[waiting[j].readyState];
});
var last = getCarefully(function(){
return toString(waiting[j].lastError);
});
if (!exit_cleanly_is_an_error) {
// For some tests (e.g. the NaCl SDK examples) it is OK if the test
// exits cleanly when we are waiting for it to load.
//
// In this case, "exiting cleanly" means returning 0 from main, or
// calling exit(0). When this happens, the module "crashes" by posting
// the "crash" message, but it also assigns an exitStatus.
//
// A real crash produces an exitStatus of -1, and if the module is still
// running its exitStatus will be undefined.
var exitStatus = getCarefully(function() {
if (ready === 'DONE') {
return waiting[j].exitStatus;
} else {
return -1;
}
});
if (exitStatus === 0) {
continue;
}
}
var msg = (name + ' did not load. Status: ' + ready + ' / ' + last);
if (load_errors_are_test_errors) {
rpc.client_error(msg);
errored = true;
} else {
rpc.log(msg);
}
}
return errored;
}
// Contains the state for a single test.
function TestStatus(tester, name, async) {
// Work around how JS binds 'this'
var this_ = this;
this.tester = tester;
this.name = name;
this.async = async;
this.running = true;
this.log = function(message) {
this.tester.rpc.log(this.name, toString(message), !this.running);
}
this.pass = function() {
// TODO raise if not running.
this.tester.rpc.pass(this.name, !this.running);
this._done();
haltAsyncTest();
}
this.fail = function(message) {
this.tester.rpc.fail(this.name, message, !this.running);
this._done();
haltAsyncTest();
}
this._done = function() {
if (this.running) {
this.running = false;
this.tester.testDone(this);
}
}
this.assert = function(condition, message) {
assert(condition, message, this);
}
this.assertEqual = function(a, b, message) {
assertEqual(a, b, message, this);
}
this.assertRegexMatches = function(a, b, message) {
assertRegexMatches(a, b, message, this);
}
this.callbackWrapper = function(callback, args) {
// A stale callback?
if (!this.running)
return;
if (args === undefined)
args = [];
try {
callback.apply(undefined, args);
} catch (err) {
if (typeof err == 'object' && 'type' in err) {
if (err.type == 'test_halt') {
// New-style test
// If we get this exception, we can assume any callbacks or next
// tests have already been scheduled.
return;
} else if (err.type == 'test_fail') {
// Old-style test
// A special exception that terminates the test with a failure
this.tester.rpc.fail(this.name, err.message, !this.running);
this._done();
return;
}
}
// This is not a special type of exception, it is an error.
this.tester.rpc.exception(this.name, err, !this.running);
this._done();
return;
}
// A normal exit. Should we move on to the next test?
// Async tests do not move on without an explicit pass.
if (!this.async) {
this.tester.rpc.pass(this.name);
this._done();
}
}
// Async callbacks should be wrapped so the tester can catch unexpected
// exceptions.
this.wrap = function(callback) {
return function() {
this_.callbackWrapper(callback, arguments);
};
}
this.setTimeout = function(callback, time) {
setTimeout(this.wrap(callback), time);
}
this.waitForCallback = function(callbackName, expectedCalls) {
this.log('Waiting for ' + expectedCalls + ' invocations of callback: '
+ callbackName);
var gotCallbacks = 0;
// Deliberately global - this is what the nexe expects.
// TODO(ncbray): consider returning this function, so the test has more
// flexibility. For example, in the test one could count to N
// using a different callback before calling _this_ callback, and
// continuing the test. Also, consider calling user-supplied callback
// when done waiting.
window[callbackName] = this.wrap(function() {
++gotCallbacks;
this_.log('Received callback ' + gotCallbacks);
if (gotCallbacks == expectedCalls) {
this_.log("Done waiting");
this_.pass();
} else {
// HACK
haltAsyncTest();
}
});
// HACK if this function is used in a non-async test, make sure we don't
// spuriously pass. Throwing this exception forces us to behave like an
// async test.
haltAsyncTest();
}
// This function takes an array of messages and asserts that the nexe
// calls PostMessage with each of these messages, in order.
// Arguments:
// plugin - The DOM object for the NaCl plugin
// messages - An array of expected responses
// callback - An optional callback function that takes the current message
// string as an argument
this.expectMessageSequence = function(plugin, messages, callback) {
this.assert(messages.length > 0, 'Must provide at least one message');
var local_messages = messages.slice();
var listener = function(message) {
if (message.data.indexOf('@:') == 0) {
// skip debug messages
this_.log('DEBUG: ' + message.data.substr(2));
} else {
this_.assertEqual(message.data, local_messages.shift());
if (callback !== undefined) {
callback(message.data);
}
}
if (local_messages.length == 0) {
this_.pass();
} else {
this_.expectEvent(plugin, 'message', listener);
}
}
this.expectEvent(plugin, 'message', listener);
}
this.expectEvent = function(src, event_type, listener) {
var wrapper = this.wrap(function(e) {
src.removeEventListener(event_type, wrapper, false);
listener(e);
});
src.addEventListener(event_type, wrapper, false);
}
}
function Tester(body_element) {
// Work around how JS binds 'this'
var this_ = this;
// The tests being run.
var tests = [];
this.rpc = new RPCWrapper();
this.waiter = new NaClWaiter(body_element);
var load_errors_are_test_errors = true;
var exit_cleanly_is_an_error = true;
var parallel = false;
//
// BEGIN public interface
//
this.loadErrorsAreOK = function() {
load_errors_are_test_errors = false;
}
this.exitCleanlyIsOK = function() {
exit_cleanly_is_an_error = false;
};
this.log = function(message) {
this.rpc.log(message);
}
// If this kind of test exits cleanly, it passes
this.addTest = function(name, testFunction) {
tests.push({name: name, callback: testFunction, async: false});
}
// This kind of test does not pass until "pass" is explicitly called.
this.addAsyncTest = function(name, testFunction) {
tests.push({name: name, callback: testFunction, async: true});
}
this.run = function() {
this.rpc.startup();
this.startHeartbeat();
this.waiter.run(
function(loaded, waiting) {
var errored = logLoadStatus(this_.rpc, load_errors_are_test_errors,
exit_cleanly_is_an_error,
loaded, waiting);
if (errored) {
this_.rpc.blankLine();
this_.rpc.log('A nexe load error occured, aborting testing.');
this_._done();
} else {
this_.startTesting();
}
},
function() {
this_.rpc.ping();
}
);
}
this.runParallel = function() {
parallel = true;
this.run();
}
// Takes an arbitrary number of arguments.
this.waitFor = function() {
for (var i = 0; i< arguments.length; i++) {
this.waiter.waitFor(arguments[i]);
}
}
//
// END public interface
//
this.startHeartbeat = function() {
var rpc = this.rpc;
var heartbeat = function() {
rpc.heartbeat();
setTimeout(heartbeat, 500);
}
heartbeat();
}
this.launchTest = function(testIndex) {
var testDecl = tests[testIndex];
var currentTest = new TestStatus(this, testDecl.name, testDecl.async);
setTimeout(currentTest.wrap(function() {
this_.rpc.blankLine();
this_.rpc.begin(currentTest.name);
testDecl.callback(currentTest);
}), 0);
}
this._done = function() {
this.rpc.blankLine();
this.rpc.shutdown();
}
this.startTesting = function() {
if (tests.length == 0) {
// No tests specified.
this._done();
return;
}
this.testCount = 0;
if (parallel) {
// Launch all tests.
for (var i = 0; i < tests.length; i++) {
this.launchTest(i);
}
} else {
// Launch the first test.
this.launchTest(0);
}
}
this.testDone = function(test) {
this.testCount += 1;
if (this.testCount < tests.length) {
if (!parallel) {
// Move on to the next test if they're being run one at a time.
this.launchTest(this.testCount);
}
} else {
this._done();
}
}
}