blob: 0e2bef21bfce46e42c56fb1df0e3177b9086e82d [file] [log] [blame]
/*
* Copyright (c) 2014 The Native Client Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*/
'use strict';
// Utilities to allow googletest style tests of apps / extensions.
/**
* @namespace.
*/
var chrometest = {};
/**
* @private
*/
chrometest.passed_ = null;
chrometest.currentTest_ = null;
chrometest.currentTestName_ = null;
chrometest.startTime_ = null;
chrometest.finishTest_ = null;
chrometest.tests_ = [];
/**
* @private
* @constant
*/
chrometest.ERROR = 'ERROR';
chrometest.WARNING = 'WARNING';
chrometest.INFO = 'INFO';
chrometest.DEBUG = 'DEBUG';
/**
* Get the decoded query parameters passed to the current page.
* @return {Object.<string>}.
*/
chrometest.getUrlParameters = function() {
var items = {};
if (window.location.search.length < 1) {
return items;
}
var fields = window.location.search.slice(1).split('&');
for (var i = 0; i < fields.length; i++) {
var parts = fields[i].split('=');
items[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1]);
}
return items;
};
/**
* Create a new messaging port to communicate with the testing extension.
* @return {PortWaiter} A new Port to the testing extension wrapped with
* PortWaiter.
*/
chrometest.newTestPort = function() {
// Pull the id out of: 'ChromeUserAgent/<ID> Chrome/<Ver>'
var extensionId = navigator.userAgent.split(' ')[0].split('/')[1];
return new chrometest.PortWaiter(chrome.runtime.connect(extensionId));
};
/**
* Kill the browser (to end the testing session).
* @return {Promise} A promise to halt (which will never be resolved because
* the browser will be halted by then).
*/
chrometest.haltBrowser = function() {
var port = chrometest.newTestPort();
port.postMessage({'name': 'haltBrowser'});
// Wait for a reply that will never come.
return port.wait();
};
/**
* Reset the connection in the testing extension.
* @returns {Promise.integer} A promise for the number of connections killed.
*/
chrometest.resetExtension = function() {
var port = chrometest.newTestPort();
var count = null;
port.postMessage({'name': 'reset'});
return port.wait().then(function(msg) {
ASSERT_EQ('resetReply', msg.name);
port.disconnect();
return msg.count;
});
}
/**
* Get a list of all loaded extensions.
*
* This exposes the result of chrome.management.getAll for use by tests.
* @returns {Promise.Array.<ExtensionInfo>}.
*/
chrometest.getAllExtensions = function() {
var port = chrometest.newTestPort();
port.postMessage({'name': 'getAllExtensions'});
return port.wait().then(function(msg) {
ASSERT_EQ('getAllExtensionsResult', msg.name);
port.disconnect();
return msg.result;
});
};
/**
* Get a mapping of process id to process info for all processes running.
*
* This exposes the result of chrome.processes.getProcessInfo for use by tests.
* @return {Promise.Object.<ProcessInfo>}.
*/
chrometest.getAllProcesses = function() {
var port = chrometest.newTestPort();
port.postMessage({'name': 'getAllProcesses'});
return port.wait().then(function(msg) {
ASSERT_EQ('getAllProcessesResult', msg.name);
port.disconnect();
return msg.result;
});
};
/**
* Create a messaging port to communicate with an extension by name.
*
* Ordinarily web pages can only communicate with extensions that have
* explicitly ask for permission in their manifests. However, extensions can
* communicate with each other without this, but should endeavor to verify that
* they only communicate with trusted peers. The testing extension should be
* whitelisted by the extensions under test when in testing mode. This allows
* the testing extension to offer web pages proxied access to extensions under
* test without modification.
* @returns {Promise.PortWaiter} A promise for a PortWaiter to communicate with
* the extension on.
*/
chrometest.proxyExtension = function(extensionName) {
var port = chrometest.newTestPort();
port.postMessage({'name': 'proxy', 'extension': extensionName});
return port.wait().then(function(msg) {
ASSERT_EQ('proxyReply', msg.name, 'expect proxy reply');
ASSERT_TRUE(
msg.success, 'should find one extension: ' + extensionName +
' found ' + msg.matchCount);
return port;
});
};
/**
* Get an URL that references the test harness.
* @param {string} path The relative path to a resource hosted by the harness.
* @return {string} The absolute URL.
*/
chrometest.harnessURL = function(path) {
var baseURL = location.href.split('/').slice(0, -1).join('/');
return baseURL + '/' + path;
};
/**
* Log a message to the test harness.
* @param {string} level The python logging level of the message.
* @param {string} message The message to log.
* @return {Promise} A promise to log it (or rejects with error code).
*/
chrometest.log = function(level, message) {
// Cap the log line limit.
var logLimit = 1024;
var rest = message.substr(logLimit);
message = message.substr(0, logLimit);
console.log(level + ': ' + message);
return chrometest.httpGet(
'/_command?log=' + encodeURIComponent(message) +
'&level=' + encodeURIComponent(level)).then(function(result) {
if (rest.length > 0) {
// Log the rest if any.
chrometest.log(level, rest);
}
});
};
/**
* Log an error message.
* @param {string} message The message to log.
* @return {Promise} A promise to do it.
*/
chrometest.error = function(message) {
return chrometest.log(chrometest.ERROR, message);
};
/**
* Log a warning message.
* @param {string} message The message to log.
* @return {Promise} A promise to do it.
*/
chrometest.warning = function(message) {
return chrometest.log(chrometest.WARNING, message);
};
/**
* Log an info message.
* @param {string} message The message to log.
* @return {Promise} A promise to do it.
*/
chrometest.info = function(message) {
return chrometest.log(chrometest.INFO, message);
};
/**
* Log a debug message.
* @param {string} message The message to log.
* @return {Promise} A promise to do it.
*/
chrometest.debug = function(message) {
return chrometest.log(chrometest.DEBUG, message);
};
/**
* Perform an HTTP GET.
* @param {string} url The URL to fetch.
* @return {Promise.string,integer} A promise for the text at the url on
* resolve or an integer with the error code on reject.
*/
chrometest.httpGet = function(url) {
return new Promise(function(resolve, reject) {
var r = new XMLHttpRequest();
r.open('GET', url, false);
r.onload = function() {
if (r.readyState == 4) {
if (r.status == 200) {
resolve(r.responseText);
} else {
reject(r.status);
}
}
}
r.send();
});
};
/**
* Sleep for a duration.
* @param {float} ms Timeout in milliseconds.
* @return {Promise} A promise to wait.
*/
chrometest.sleep = function(ms) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve();
}, ms);
});
};
/**
* Format a time in milliseconds to XXms or YYs as appropriate.
* @param {float} ms Time in milliseconds.
* @return {string} A formatted time.
*/
chrometest.formatDuration = function(ms) {
if (ms < 1000.0) {
return ms + 'ms';
} else {
return (ms / 1000.0).toFixed(1) + 's';
}
};
/**
* Tell the test harness how many test runs to expect.
* @param {integer} testCount The number of tests to expect.
* @return {Promise} A promise to do it.
*/
chrometest.reportTestCount_ = function(testCount) {
console.log('About to run ' + testCount + ' tests.');
return chrometest.httpGet('/_command?test_count=' + testCount);
};
/**
* Notify the test harness that a test has begun.
* @param {string} name The full name of the test.
* @return {Promise} A promise to do it.
*/
chrometest.beginTest_ = function(name) {
return chrometest.resetExtension().then(function(count) {
if (count != 0) {
throw new Error(
'Test extension connections from the last test remain active!');
}
console.log('[ RUN ] ' + name);
chrometest.passed_ = true;
chrometest.currentTestName_ = name;
return chrometest.httpGet(
'/_command?name=' + encodeURIComponent(name) +
'&start=1');
}).then(function(result) {
chrometest.startTime_ = new Date();
});
};
/**
* Notify the test harness that a test has ended.
* @return {Promise} A promise to do it.
*/
chrometest.endTest_ = function() {
return chrometest.resetExtension().then(function(count) {
EXPECT_EQ(0, count,
'all connection to the test extension should be closed');
var endTime = new Date();
var duration = endTime.getTime() - chrometest.startTime_.getTime();
duration = chrometest.formatDuration(duration);
var name = chrometest.currentTestName_;
if (chrometest.passed_) {
var resultMsg = ' OK';
var result = 1;
} else {
var resultMsg = ' FAILED ';
var result = 0;
}
console.log('[ ' + resultMsg + ' ] ' + name + ' (' + duration + ')');
chrometest.startTime_ = null;
chrometest.currentTest_ = null;
chrometest.currentTestName_ = null;
return chrometest.httpGet(
'/_command?name=' + encodeURIComponent(name) + '&' +
'duration=' + encodeURIComponent(duration) + '&' +
'result=' + result);
});
};
/**
* Mark current test as failed.
*/
chrometest.fail = function() {
chrometest.passed_ = false;
};
/**
* Format an error object as a string.
* Error objects use their stack trace.
* @param {?} error A thrown value.
*/
chrometest.formatError = function(error) {
if (error === undefined || error.stack === undefined) {
return '' + error;
} else {
return error.stack;
}
};
/**
* Assert that something must be true to continue the current test.
*
* This halts the current test by throwing an exception.
* Unfortunately, this has the danger that it may not actually halt the test.
* Ideally, any exception handling in the test itself should be done very
* carefully to ensure it passes along 'assert' exceptions.
* If the code under test eats the exception, at least the test will be marked
* as failed. If the exception causes the code under test to wait indefinitely,
* the timeout in the testing harness will eventually bring everything down.
*
* Halts the current test if the condition is not true.
* @param {boolean} condition A condition to check.
* @param {string} description A description of the context in which the
* condition is being checked (to help
* label / find it).
*/
chrometest.assert = function(condition, description) {
if (!condition) {
chrometest.fail();
if (description === undefined) {
description = 'no description';
}
var error = new Error('ASSERT FAILED! - ' + description);
chrometest.error(chrometest.formatError(error)).then(function() {
throw 'assert';
});
}
};
/**
* Declare that something must be true for the current test to pass.
*
* Does not halt the current test if the condition is false, but does emit
* information on the failure location and mark the test as failed.
* @param {boolean} condition A condition to check.
* @param {string} description A description of the context in which the
* condition is being checked (to help
* label / find it).
*/
chrometest.expect = function(condition, description) {
if (!condition) {
chrometest.fail();
if (description === undefined) {
description = 'no description';
}
var error = new Error('EXPECT FAILED! - ' + description);
chrometest.error(chrometest.formatError(error));
}
};
/**
* Run a list of tests.
* param {Array.<Test>} testList The list of tests to run.
* @return {Promise} A promise to do it.
*/
chrometest.runTests_ = function(testList) {
var p = Promise.resolve();
testList.forEach(function(test) {
p = p.then(function() {
return test.call();
});
});
return p;
};
/**
* Check if a string matches a wildcard string.
* @param string filter A wildcard string (* - any string, ? - one char).
* @param string s A string to match.
*/
chrometest.wildcardMatch = function(filter, s) {
filter = filter.replace(/[.]/g, '[.]');
filter = filter.replace(/\*/g, '.*');
filter = filter.replace(/\?/g, '.');
filter = '^' + filter + '$';
var re = new RegExp(filter);
return re.test(s);
};
/**
* Check if a string matches a googletest style filter.
* A filter consists of zero or more ':' separated positive wildcard
* strings, followed optionally by a '-' and zero or more ':' separated
* negative wildcard strings.
* @param string filter A googletest style filter string.
* @param string s A string to match.
*/
chrometest.filterMatch = function(filter, s) {
var parts = filter.split('-');
if (parts.length == 1) {
var positive = parts[0].split(':');
var negative = [];
} else if (parts.length == 2) {
var positive = parts[0].split(':');
var negative = parts[1].split(':');
} else {
// Treat ill-formated filters as non-matches.
return false;
}
if (positive.length == 1 && positive[0] == '') {
positive = ['*'];
}
if (negative.length == 1 && negative[0] == '') {
negative = [];
}
for (var i = 0; i < positive.length; i++) {
if (!chrometest.wildcardMatch(positive[i], s)) {
return false;
}
}
for (var i = 0; i < negative.length; i++) {
if (chrometest.wildcardMatch(negative[i], s)) {
return false;
}
}
return true;
};
/**
* Filter tests based on harness filter.
* @returns {Promose} A promise to do it.
*/
chrometest.filterTests_ = function() {
return chrometest.httpGet('/_command?filter=1').then(function(filter) {
var keep = [];
var tests = chrometest.tests_;
for (var i = 0; i < tests.length; i++) {
if (chrometest.filterMatch(filter, tests[i].name)) {
keep.push(tests[i]);
}
}
chrometest.tests_ = keep;
}).catch(function(responseCode) {
throw new Error(
'Requesting filter from test harness failed! (code: ' +
responseCode + ')');
});
};
/**
* Report the test count and run all register tests and halt the browser.
* @return {Promise} A promise to do it.
*/
chrometest.runAllTests_ = function() {
return Promise.resolve().then(function() {
return chrometest.filterTests_();
}).then(function() {
// Sleep 100ms before starting the tests as extensions may not load
// simultaneously.
return chrometest.sleep(100);
}).then(function() {
return chrometest.reportTestCount_(chrometest.tests_.length);
}).then(function() {
return chrometest.runTests_(chrometest.tests_);
}).catch(function(error) {
chrometest.fail();
return chrometest.error(chrometest.formatError(error));
}).then(function() {
return chrometest.haltBrowser();
});
};
/**
* Load a javascript module.
* @param {string} filename Filename to load.
* @return {Promise} A promise to load the module.
*/
chrometest.load = function(filename) {
return new Promise(function(resolve, reject) {
// Register a window wide handler just in case (things leak thru).
window.onerror = function(
errorMsg, url, lineNumber, columnNumber, error) {
chrometest.fail();
chrometest.error(
errorMsg + ' in ' + url + ' at ' +
lineNumber + ':' + columnNumber + '\n' +
chrometest.formatError(error)).then(function() {
reject(chrometest.haltBrowser());
});
};
var script = document.createElement('script');
script.src = filename;
script.onerror = function(e) {
chrometest.error(
'Error loading ' + e.target.src + '\n').then(function() {
reject(chrometest.haltBrowser());
});
};
script.onload = function() {
resolve();
};
document.body.appendChild(script);
});
};
/**
* Load a list of javascript files into script tags.
* @param {Array.<string>} sources A list of javascript files to load tests
* from.
*/
chrometest.run = function(sources) {
var p = Promise.resolve();
sources.forEach(function(filename) {
p = p.then(function() {
return chrometest.load(filename);
});
});
return p.then(function() {
return chrometest.runAllTests_();
});
};
/**
* An class that monitors an object that behaves like a messaging Port or an
* event listener, allowing Promise yielding waits.
* Descendants or wrappers will want to perform port type specific setup and
* tear down.
* @constructor
* @param {function()} tearDown A function called to detach any handles
* associated with the port.
* @param {Object} port An object that implements postMessage.
*/
chrometest.PortlikeWaiter = function(tearDown, port) {
var self = this;
self.port_ = port;
self.messages_ = [];
self.waiter_ = null;
self.tearDown_ = tearDown;
};
/**
* Enqueue a message to any waiter.
* @param {Promise.Object} A Promise for a message.
*/
chrometest.PortlikeWaiter.prototype.enqueue = function(msg) {
var self = this;
if (self.waiter_ !== null) {
self.waiter_(msg);
} else {
self.messages_.push(msg);
}
};
/**
* Wait a message.
* @return {Promise.Object} A promise for a message.
*/
chrometest.PortlikeWaiter.prototype.wait = function() {
var self = this;
return new Promise(function(resolve) {
if (self.messages_.length > 0) {
var msg = self.messages_.shift();
resolve(msg);
} else {
if (self.waiter_ !== null) {
throw new Error('Multiple waiters on a PortlikeWaiter!');
}
self.waiter_ = function(msg) {
self.waiter_ = null;
resolve(msg);
};
}
});
};
/**
* Post a message to the port object associated with this waiter.
*/
chrometest.PortlikeWaiter.prototype.postMessage = function() {
this.port_.postMessage.apply(this.port_, arguments);
};
/**
* Detach the port object wrapper by this waiter for use.
* @return {Object} The port like object managed by this object.
*/
chrometest.PortlikeWaiter.prototype.detach = function() {
var self = this;
var port = self.port_;
self.port_ = null;
self.messages_ = null;
self.waiter_ = null;
if (self.tearDown_) {
var tearDown = self.tearDown_;
self.tearDown_ = null;
tearDown();
}
return port;
};
/**
* An object that monitors a messaging port and doles out promises.
* Takes ownership of the port. Calls to postMessage and disconnect on the port
* should be down to the waiter instead.
* Detach can be used to release the underlying Port.
* @constructor
* @param {Port} port The port to monitor.
*/
chrometest.PortWaiter = function(port) {
var self = this;
function handleMessage(msg) {
self.enqueue(Promise.resolve(msg));
}
function handleDisconnect() {
self.enqueue(Promise.reject());
self.detach();
}
chrometest.PortlikeWaiter.call(self, function() {
port.onMessage.removeListener(handleMessage);
port.onDisconnect.removeListener(handleDisconnect);
}, port);
self.port_.onMessage.addListener(handleMessage);
self.port_.onDisconnect.addListener(handleDisconnect);
};
chrometest.PortWaiter.prototype = new chrometest.PortlikeWaiter();
/**
* Disconnect the Port object wrapped by this waiter.
* @param {Object} msg Message to send.
*/
chrometest.PortWaiter.prototype.disconnect = function() {
var self = this;
var port = self.detach();
if (port !== null) {
port.disconnect();
}
};
/**
* A test case.
* @constructor
*/
chrometest.Test = function() {
};
/**
* The default setUp method for a test case (does nothing).
* @return {Promise/void} Optionally return a promise to set up.
*/
chrometest.Test.prototype.setUp = function() {
};
/**
* The default tearDown method for a test case (does nothing).
* @return {Promise/void} Optionally return a promise to tear down.
*/
chrometest.Test.prototype.tearDown = function() {
};
// Below this point functions are declare at global scope and use a naming
// convention that matches googletest. This done for several reasons:
// - A global name makes use through multiple tests convenient.
// - Using an existing naming convention makes use and intent clear.
// - ALL CAPS highlights the testing constructs visually.
// TEST Types
// ----------
/**
* Register a test case using a fixture class.
* @param {string} fixtureClass The test fixture class object.
* @param {string} testName The name of the test.
* @param {function()} testFunc Called to run the test, may return
* a Promise.
* @param {string} opt_caseName Optional name for the case, otherwise the
* fixtureClass class name is used.
*/
function TEST_F(fixtureClass, testName, testFunc, opt_caseName) {
if (opt_caseName == undefined) {
opt_caseName = fixtureClass.name;
}
var fullName = opt_caseName + '.' + testName;
chrometest.tests_.push({
'name': fullName,
'call': function() {
return Promise.resolve().then(function() {
return chrometest.beginTest_(fullName);
}).then(function() {
chrometest.currentTest_ = new fixtureClass();
return Promise.resolve().then(function() {
return chrometest.currentTest_.setUp();
}).then(function() {
return Promise.resolve().then(function() {
return testFunc.call(chrometest.currentTest_);
}).catch(function(error) {
chrometest.fail();
return chrometest.error(chrometest.formatError(error));
});
}).then(function() {
return chrometest.currentTest_.tearDown();
}).catch(function(error) {
chrometest.fail();
return chrometest.error(chrometest.formatError(error));
});
}).then(function() {
return chrometest.endTest_();
});
},
});
}
/**
* Register a single test.
* @param {string} testCase A test case name in lieu of a fixture.
* @param {string} testName The name of the test.
* @param {function()} testFunc Called to run the test, may return
* a Promise.
*/
function TEST(testCase, testName, testFunc) {
TEST_F(chrometest.Test, testName, testFunc, testCase);
}
// ASSERT VARIANTS
// ---------------
function ASSERT_EQ(expected, actual, context) {
expected = JSON.stringify(expected);
actual = JSON.stringify(actual);
chrometest.assert(expected == actual, 'Expected ' + expected + ' but got ' +
JSON.stringify(actual) + ' when ' + context);
}
function ASSERT_NE(expected, actual, context) {
expected = JSON.stringify(expected);
actual = JSON.stringify(actual);
chrometest.assert(expected != actual, 'Did not expect ' + expected +
' but got ' + actual + ' when ' + context);
}
function ASSERT_TRUE(value, context) {
ASSERT_EQ(true, value, context);
}
function ASSERT_FALSE(value, context) {
ASSERT_EQ(false, value, context);
}
function ASSERT_LT(a, b, context) {
chrometest.assert(a < b, 'Expected ' + a + ' < ' + b + ' when ' + context);
}
function ASSERT_GT(a, b, context) {
chrometest.assert(a > b, 'Expected ' + a + ' > ' + b + ' when ' + context);
}
function ASSERT_LE(a, b, context) {
chrometest.assert(a <= b, 'Expected ' + a + ' <= ' + b + ' when ' + context);
}
function ASSERT_GE(a, b, context) {
chrometest.assert(a >= b, 'Expected ' + a + ' >= ' + b + ' when ' + context);
}
// EXPECT VARIANTS
// ---------------
function EXPECT_EQ(expected, actual, context) {
expected = JSON.stringify(expected);
actual = JSON.stringify(actual);
chrometest.expect(expected == actual, 'Expected ' + expected + ' but got ' +
JSON.stringify(actual) + ' when ' + context);
}
function EXPECT_NE(expected, actual, context) {
expected = JSON.stringify(expected);
actual = JSON.stringify(actual);
chrometest.expect(expected != actual, 'Did not expect ' + expected +
' but got ' + actual + ' when ' + context);
}
function EXPECT_TRUE(value, context) {
EXPECT_EQ(true, value, context);
}
function EXPECT_FALSE(value, context) {
EXPECT_EQ(false, value, context);
}
function EXPECT_LT(a, b, context) {
chrometest.expect(a < b, 'Expected ' + a + ' < ' + b + ' when ' + context);
}
function EXPECT_GT(a, b, context) {
chrometest.expect(a > b, 'Expected ' + a + ' > ' + b + ' when ' + context);
}
function EXPECT_LE(a, b, context) {
chrometest.expect(a <= b, 'Expected ' + a + ' <= ' + b + ' when ' + context);
}
function EXPECT_GE(a, b, context) {
chrometest.expect(a >= b, 'Expected ' + a + ' >= ' + b + ' when ' + context);
}