wpt-runner
Version:
Runs web platform tests in Node.js using jsdom
1,252 lines (1,141 loc) • 185 kB
JavaScript
/*global self*/
/*jshint latedef: nofunc*/
/* Documentation: https://web-platform-tests.org/writing-tests/testharness-api.html
* (../docs/_writing-tests/testharness-api.md) */
(function (global_scope)
{
// default timeout is 10 seconds, test can override if needed
var settings = {
output:true,
harness_timeout:{
"normal":10000,
"long":60000
},
test_timeout:null,
message_events: ["start", "test_state", "result", "completion"],
debug: false,
};
var xhtml_ns = "http://www.w3.org/1999/xhtml";
/*
* TestEnvironment is an abstraction for the environment in which the test
* harness is used. Each implementation of a test environment has to provide
* the following interface:
*
* interface TestEnvironment {
* // Invoked after the global 'tests' object has been created and it's
* // safe to call add_*_callback() to register event handlers.
* void on_tests_ready();
*
* // Invoked after setup() has been called to notify the test environment
* // of changes to the test harness properties.
* void on_new_harness_properties(object properties);
*
* // Should return a new unique default test name.
* DOMString next_default_test_name();
*
* // Should return the test harness timeout duration in milliseconds.
* float test_timeout();
* };
*/
/*
* A test environment with a DOM. The global object is 'window'. By default
* test results are displayed in a table. Any parent windows receive
* callbacks or messages via postMessage() when test events occur. See
* apisample11.html and apisample12.html.
*/
function WindowTestEnvironment() {
this.name_counter = 0;
this.window_cache = null;
this.output_handler = null;
this.all_loaded = false;
var this_obj = this;
this.message_events = [];
this.dispatched_messages = [];
this.message_functions = {
start: [add_start_callback, remove_start_callback,
function (properties) {
this_obj._dispatch("start_callback", [properties],
{type: "start", properties: properties});
}],
test_state: [add_test_state_callback, remove_test_state_callback,
function(test) {
this_obj._dispatch("test_state_callback", [test],
{type: "test_state",
test: test.structured_clone()});
}],
result: [add_result_callback, remove_result_callback,
function (test) {
this_obj.output_handler.show_status();
this_obj._dispatch("result_callback", [test],
{type: "result",
test: test.structured_clone()});
}],
completion: [add_completion_callback, remove_completion_callback,
function (tests, harness_status, asserts) {
var cloned_tests = map(tests, function(test) {
return test.structured_clone();
});
this_obj._dispatch("completion_callback", [tests, harness_status],
{type: "complete",
tests: cloned_tests,
status: harness_status.structured_clone(),
asserts: asserts.map(assert => assert.structured_clone())});
}]
}
on_event(window, 'load', function() {
this_obj.all_loaded = true;
});
on_event(window, 'message', function(event) {
if (event.data && event.data.type === "getmessages" && event.source) {
// A window can post "getmessages" to receive a duplicate of every
// message posted by this environment so far. This allows subscribers
// from fetch_tests_from_window to 'catch up' to the current state of
// this environment.
for (var i = 0; i < this_obj.dispatched_messages.length; ++i)
{
event.source.postMessage(this_obj.dispatched_messages[i], "*");
}
}
});
}
WindowTestEnvironment.prototype._dispatch = function(selector, callback_args, message_arg) {
this.dispatched_messages.push(message_arg);
this._forEach_windows(
function(w, same_origin) {
if (same_origin) {
try {
var has_selector = selector in w;
} catch(e) {
// If document.domain was set at some point same_origin can be
// wrong and the above will fail.
has_selector = false;
}
if (has_selector) {
try {
w[selector].apply(undefined, callback_args);
} catch (e) {}
}
}
if (w !== self) {
w.postMessage(message_arg, "*");
}
});
};
WindowTestEnvironment.prototype._forEach_windows = function(callback) {
// Iterate over the windows [self ... top, opener]. The callback is passed
// two objects, the first one is the window object itself, the second one
// is a boolean indicating whether or not it's on the same origin as the
// current window.
var cache = this.window_cache;
if (!cache) {
cache = [[self, true]];
var w = self;
var i = 0;
var so;
while (w != w.parent) {
w = w.parent;
so = is_same_origin(w);
cache.push([w, so]);
i++;
}
w = window.opener;
if (w) {
cache.push([w, is_same_origin(w)]);
}
this.window_cache = cache;
}
forEach(cache,
function(a) {
callback.apply(null, a);
});
};
WindowTestEnvironment.prototype.on_tests_ready = function() {
var output = new Output();
this.output_handler = output;
var this_obj = this;
add_start_callback(function (properties) {
this_obj.output_handler.init(properties);
});
add_test_state_callback(function(test) {
this_obj.output_handler.show_status();
});
add_result_callback(function (test) {
this_obj.output_handler.show_status();
});
add_completion_callback(function (tests, harness_status, asserts_run) {
this_obj.output_handler.show_results(tests, harness_status, asserts_run);
});
this.setup_messages(settings.message_events);
};
WindowTestEnvironment.prototype.setup_messages = function(new_events) {
var this_obj = this;
forEach(settings.message_events, function(x) {
var current_dispatch = this_obj.message_events.indexOf(x) !== -1;
var new_dispatch = new_events.indexOf(x) !== -1;
if (!current_dispatch && new_dispatch) {
this_obj.message_functions[x][0](this_obj.message_functions[x][2]);
} else if (current_dispatch && !new_dispatch) {
this_obj.message_functions[x][1](this_obj.message_functions[x][2]);
}
});
this.message_events = new_events;
}
WindowTestEnvironment.prototype.next_default_test_name = function() {
var suffix = this.name_counter > 0 ? " " + this.name_counter : "";
this.name_counter++;
return get_title() + suffix;
};
WindowTestEnvironment.prototype.on_new_harness_properties = function(properties) {
this.output_handler.setup(properties);
if (properties.hasOwnProperty("message_events")) {
this.setup_messages(properties.message_events);
}
};
WindowTestEnvironment.prototype.add_on_loaded_callback = function(callback) {
on_event(window, 'load', callback);
};
WindowTestEnvironment.prototype.test_timeout = function() {
var metas = document.getElementsByTagName("meta");
for (var i = 0; i < metas.length; i++) {
if (metas[i].name == "timeout") {
if (metas[i].content == "long") {
return settings.harness_timeout.long;
}
break;
}
}
return settings.harness_timeout.normal;
};
/*
* Base TestEnvironment implementation for a generic web worker.
*
* Workers accumulate test results. One or more clients can connect and
* retrieve results from a worker at any time.
*
* WorkerTestEnvironment supports communicating with a client via a
* MessagePort. The mechanism for determining the appropriate MessagePort
* for communicating with a client depends on the type of worker and is
* implemented by the various specializations of WorkerTestEnvironment
* below.
*
* A client document using testharness can use fetch_tests_from_worker() to
* retrieve results from a worker. See apisample16.html.
*/
function WorkerTestEnvironment() {
this.name_counter = 0;
this.all_loaded = true;
this.message_list = [];
this.message_ports = [];
}
WorkerTestEnvironment.prototype._dispatch = function(message) {
this.message_list.push(message);
for (var i = 0; i < this.message_ports.length; ++i)
{
this.message_ports[i].postMessage(message);
}
};
// The only requirement is that port has a postMessage() method. It doesn't
// have to be an instance of a MessagePort, and often isn't.
WorkerTestEnvironment.prototype._add_message_port = function(port) {
this.message_ports.push(port);
for (var i = 0; i < this.message_list.length; ++i)
{
port.postMessage(this.message_list[i]);
}
};
WorkerTestEnvironment.prototype.next_default_test_name = function() {
var suffix = this.name_counter > 0 ? " " + this.name_counter : "";
this.name_counter++;
return get_title() + suffix;
};
WorkerTestEnvironment.prototype.on_new_harness_properties = function() {};
WorkerTestEnvironment.prototype.on_tests_ready = function() {
var this_obj = this;
add_start_callback(
function(properties) {
this_obj._dispatch({
type: "start",
properties: properties,
});
});
add_test_state_callback(
function(test) {
this_obj._dispatch({
type: "test_state",
test: test.structured_clone()
});
});
add_result_callback(
function(test) {
this_obj._dispatch({
type: "result",
test: test.structured_clone()
});
});
add_completion_callback(
function(tests, harness_status, asserts) {
this_obj._dispatch({
type: "complete",
tests: map(tests,
function(test) {
return test.structured_clone();
}),
status: harness_status.structured_clone(),
asserts: asserts.map(assert => assert.structured_clone()),
});
});
};
WorkerTestEnvironment.prototype.add_on_loaded_callback = function() {};
WorkerTestEnvironment.prototype.test_timeout = function() {
// Tests running in a worker don't have a default timeout. I.e. all
// worker tests behave as if settings.explicit_timeout is true.
return null;
};
/*
* Dedicated web workers.
* https://html.spec.whatwg.org/multipage/workers.html#dedicatedworkerglobalscope
*
* This class is used as the test_environment when testharness is running
* inside a dedicated worker.
*/
function DedicatedWorkerTestEnvironment() {
WorkerTestEnvironment.call(this);
// self is an instance of DedicatedWorkerGlobalScope which exposes
// a postMessage() method for communicating via the message channel
// established when the worker is created.
this._add_message_port(self);
}
DedicatedWorkerTestEnvironment.prototype = Object.create(WorkerTestEnvironment.prototype);
DedicatedWorkerTestEnvironment.prototype.on_tests_ready = function() {
WorkerTestEnvironment.prototype.on_tests_ready.call(this);
// In the absence of an onload notification, we a require dedicated
// workers to explicitly signal when the tests are done.
tests.wait_for_finish = true;
};
/*
* Shared web workers.
* https://html.spec.whatwg.org/multipage/workers.html#sharedworkerglobalscope
*
* This class is used as the test_environment when testharness is running
* inside a shared web worker.
*/
function SharedWorkerTestEnvironment() {
WorkerTestEnvironment.call(this);
var this_obj = this;
// Shared workers receive message ports via the 'onconnect' event for
// each connection.
self.addEventListener("connect",
function(message_event) {
this_obj._add_message_port(message_event.source);
}, false);
}
SharedWorkerTestEnvironment.prototype = Object.create(WorkerTestEnvironment.prototype);
SharedWorkerTestEnvironment.prototype.on_tests_ready = function() {
WorkerTestEnvironment.prototype.on_tests_ready.call(this);
// In the absence of an onload notification, we a require shared
// workers to explicitly signal when the tests are done.
tests.wait_for_finish = true;
};
/*
* Service workers.
* http://www.w3.org/TR/service-workers/
*
* This class is used as the test_environment when testharness is running
* inside a service worker.
*/
function ServiceWorkerTestEnvironment() {
WorkerTestEnvironment.call(this);
this.all_loaded = false;
this.on_loaded_callback = null;
var this_obj = this;
self.addEventListener("message",
function(event) {
if (event.data && event.data.type && event.data.type === "connect") {
this_obj._add_message_port(event.source);
}
}, false);
// The oninstall event is received after the service worker script and
// all imported scripts have been fetched and executed. It's the
// equivalent of an onload event for a document. All tests should have
// been added by the time this event is received, thus it's not
// necessary to wait until the onactivate event. However, tests for
// installed service workers need another event which is equivalent to
// the onload event because oninstall is fired only on installation. The
// onmessage event is used for that purpose since tests using
// testharness.js should ask the result to its service worker by
// PostMessage. If the onmessage event is triggered on the service
// worker's context, that means the worker's script has been evaluated.
on_event(self, "install", on_all_loaded);
on_event(self, "message", on_all_loaded);
function on_all_loaded() {
if (this_obj.all_loaded)
return;
this_obj.all_loaded = true;
if (this_obj.on_loaded_callback) {
this_obj.on_loaded_callback();
}
}
}
ServiceWorkerTestEnvironment.prototype = Object.create(WorkerTestEnvironment.prototype);
ServiceWorkerTestEnvironment.prototype.add_on_loaded_callback = function(callback) {
if (this.all_loaded) {
callback();
} else {
this.on_loaded_callback = callback;
}
};
/*
* Shadow realms.
* https://github.com/tc39/proposal-shadowrealm
*
* This class is used as the test_environment when testharness is running
* inside a shadow realm.
*/
function ShadowRealmTestEnvironment() {
WorkerTestEnvironment.call(this);
this.all_loaded = false;
this.on_loaded_callback = null;
}
ShadowRealmTestEnvironment.prototype = Object.create(WorkerTestEnvironment.prototype);
/**
* Signal to the test environment that the tests are ready and the on-loaded
* callback should be run.
*
* Shadow realms are not *really* a DOM context: they have no `onload` or similar
* event for us to use to set up the test environment; so, instead, this method
* is manually triggered from the incubating realm
*
* @param {Function} message_destination - a function that receives JSON-serializable
* data to send to the incubating realm, in the same format as used by RemoteContext
*/
ShadowRealmTestEnvironment.prototype.begin = function(message_destination) {
if (this.all_loaded) {
throw new Error("Tried to start a shadow realm test environment after it has already started");
}
var fakeMessagePort = {};
fakeMessagePort.postMessage = message_destination;
this._add_message_port(fakeMessagePort);
this.all_loaded = true;
if (this.on_loaded_callback) {
this.on_loaded_callback();
}
};
ShadowRealmTestEnvironment.prototype.add_on_loaded_callback = function(callback) {
if (this.all_loaded) {
callback();
} else {
this.on_loaded_callback = callback;
}
};
/*
* JavaScript shells.
*
* This class is used as the test_environment when testharness is running
* inside a JavaScript shell.
*/
function ShellTestEnvironment() {
this.name_counter = 0;
this.all_loaded = false;
this.on_loaded_callback = null;
Promise.resolve().then(function() {
this.all_loaded = true
if (this.on_loaded_callback) {
this.on_loaded_callback();
}
}.bind(this));
this.message_list = [];
this.message_ports = [];
}
ShellTestEnvironment.prototype.next_default_test_name = function() {
var suffix = this.name_counter > 0 ? " " + this.name_counter : "";
this.name_counter++;
return get_title() + suffix;
};
ShellTestEnvironment.prototype.on_new_harness_properties = function() {};
ShellTestEnvironment.prototype.on_tests_ready = function() {};
ShellTestEnvironment.prototype.add_on_loaded_callback = function(callback) {
if (this.all_loaded) {
callback();
} else {
this.on_loaded_callback = callback;
}
};
ShellTestEnvironment.prototype.test_timeout = function() {
// Tests running in a shell don't have a default timeout, so behave as
// if settings.explicit_timeout is true.
return null;
};
function create_test_environment() {
if ('document' in global_scope) {
return new WindowTestEnvironment();
}
if ('DedicatedWorkerGlobalScope' in global_scope &&
global_scope instanceof DedicatedWorkerGlobalScope) {
return new DedicatedWorkerTestEnvironment();
}
if ('SharedWorkerGlobalScope' in global_scope &&
global_scope instanceof SharedWorkerGlobalScope) {
return new SharedWorkerTestEnvironment();
}
if ('ServiceWorkerGlobalScope' in global_scope &&
global_scope instanceof ServiceWorkerGlobalScope) {
return new ServiceWorkerTestEnvironment();
}
if ('WorkerGlobalScope' in global_scope &&
global_scope instanceof WorkerGlobalScope) {
return new DedicatedWorkerTestEnvironment();
}
/* Shadow realm global objects are _ordinary_ objects (i.e. their prototype is
* Object) so we don't have a nice `instanceof` test to use; instead, we
* check if the there is a GLOBAL.isShadowRealm() property
* on the global object. that was set by the test harness when it
* created the ShadowRealm.
*/
if (global_scope.GLOBAL && global_scope.GLOBAL.isShadowRealm()) {
return new ShadowRealmTestEnvironment();
}
return new ShellTestEnvironment();
}
var test_environment = create_test_environment();
function is_shared_worker(worker) {
return 'SharedWorker' in global_scope && worker instanceof SharedWorker;
}
function is_service_worker(worker) {
// The worker object may be from another execution context,
// so do not use instanceof here.
return 'ServiceWorker' in global_scope &&
Object.prototype.toString.call(worker) == '[object ServiceWorker]';
}
var seen_func_name = Object.create(null);
function get_test_name(func, name)
{
if (name) {
return name;
}
if (func) {
var func_code = func.toString();
// Try and match with brackets, but fallback to matching without
var arrow = func_code.match(/^\(\)\s*=>\s*(?:{(.*)}\s*|(.*))$/);
// Check for JS line separators
if (arrow !== null && !/[\u000A\u000D\u2028\u2029]/.test(func_code)) {
var trimmed = (arrow[1] !== undefined ? arrow[1] : arrow[2]).trim();
// drop trailing ; if there's no earlier ones
trimmed = trimmed.replace(/^([^;]*)(;\s*)+$/, "$1");
if (trimmed) {
let name = trimmed;
if (seen_func_name[trimmed]) {
// This subtest name already exists, so add a suffix.
name += " " + seen_func_name[trimmed];
} else {
seen_func_name[trimmed] = 0;
}
seen_func_name[trimmed] += 1;
return name;
}
}
}
return test_environment.next_default_test_name();
}
/**
* @callback TestFunction
* @param {Test} test - The test currnetly being run.
* @param {Any[]} args - Additional args to pass to function.
*
*/
/**
* Create a synchronous test
*
* @param {TestFunction} func - Test function. This is executed
* immediately. If it returns without error, the test status is
* set to ``PASS``. If it throws an :js:class:`AssertionError`, or
* any other exception, the test status is set to ``FAIL``
* (typically from an `assert` function).
* @param {String} name - Test name. This must be unique in a
* given file and must be invariant between runs.
*/
function test(func, name, properties)
{
if (tests.promise_setup_called) {
tests.status.status = tests.status.ERROR;
tests.status.message = '`test` invoked after `promise_setup`';
tests.complete();
}
var test_name = get_test_name(func, name);
var test_obj = new Test(test_name, properties);
var value = test_obj.step(func, test_obj, test_obj);
if (value !== undefined) {
var msg = 'Test named "' + test_name +
'" passed a function to `test` that returned a value.';
try {
if (value && typeof value.then === 'function') {
msg += ' Consider using `promise_test` instead when ' +
'using Promises or async/await.';
}
} catch (err) {}
tests.status.status = tests.status.ERROR;
tests.status.message = msg;
}
if (test_obj.phase === test_obj.phases.STARTED) {
test_obj.done();
}
}
/**
* Create an asynchronous test
*
* @param {TestFunction|string} funcOrName - Initial step function
* to call immediately with the test name as an argument (if any),
* or name of the test.
* @param {String} name - Test name (if a test function was
* provided). This must be unique in a given file and must be
* invariant between runs.
* @returns {Test} An object representing the ongoing test.
*/
function async_test(func, name, properties)
{
if (tests.promise_setup_called) {
tests.status.status = tests.status.ERROR;
tests.status.message = '`async_test` invoked after `promise_setup`';
tests.complete();
}
if (typeof func !== "function") {
properties = name;
name = func;
func = null;
}
var test_name = get_test_name(func, name);
var test_obj = new Test(test_name, properties);
if (func) {
var value = test_obj.step(func, test_obj, test_obj);
// Test authors sometimes return values to async_test, expecting us
// to handle the value somehow. Make doing so a harness error to be
// clear this is invalid, and point authors to promise_test if it
// may be appropriate.
//
// Note that we only perform this check on the initial function
// passed to async_test, not on any later steps - we haven't seen a
// consistent problem with those (and it's harder to check).
if (value !== undefined) {
var msg = 'Test named "' + test_name +
'" passed a function to `async_test` that returned a value.';
try {
if (value && typeof value.then === 'function') {
msg += ' Consider using `promise_test` instead when ' +
'using Promises or async/await.';
}
} catch (err) {}
tests.set_status(tests.status.ERROR, msg);
tests.complete();
}
}
return test_obj;
}
/**
* Create a promise test.
*
* Promise tests are tests which are represented by a promise
* object. If the promise is fulfilled the test passes, if it's
* rejected the test fails, otherwise the test passes.
*
* @param {TestFunction} func - Test function. This must return a
* promise. The test is automatically marked as complete once the
* promise settles.
* @param {String} name - Test name. This must be unique in a
* given file and must be invariant between runs.
*/
function promise_test(func, name, properties) {
if (typeof func !== "function") {
properties = name;
name = func;
func = null;
}
var test_name = get_test_name(func, name);
var test = new Test(test_name, properties);
test._is_promise_test = true;
// If there is no promise tests queue make one.
if (!tests.promise_tests) {
tests.promise_tests = Promise.resolve();
}
tests.promise_tests = tests.promise_tests.then(function() {
return new Promise(function(resolve) {
var promise = test.step(func, test, test);
test.step(function() {
assert(!!promise, "promise_test", null,
"test body must return a 'thenable' object (received ${value})",
{value:promise});
assert(typeof promise.then === "function", "promise_test", null,
"test body must return a 'thenable' object (received an object with no `then` method)",
null);
});
// Test authors may use the `step` method within a
// `promise_test` even though this reflects a mixture of
// asynchronous control flow paradigms. The "done" callback
// should be registered prior to the resolution of the
// user-provided Promise to avoid timeouts in cases where the
// Promise does not settle but a `step` function has thrown an
// error.
add_test_done_callback(test, resolve);
Promise.resolve(promise)
.catch(test.step_func(
function(value) {
if (value instanceof AssertionError) {
throw value;
}
assert(false, "promise_test", null,
"Unhandled rejection with value: ${value}", {value:value});
}))
.then(function() {
test.done();
});
});
});
}
/**
* Make a copy of a Promise in the current realm.
*
* @param {Promise} promise the given promise that may be from a different
* realm
* @returns {Promise}
*
* An arbitrary promise provided by the caller may have originated
* in another frame that have since navigated away, rendering the
* frame's document inactive. Such a promise cannot be used with
* `await` or Promise.resolve(), as microtasks associated with it
* may be prevented from being run. See `issue
* 5319<https://github.com/whatwg/html/issues/5319>`_ for a
* particular case.
*
* In functions we define here, there is an expectation from the caller
* that the promise is from the current realm, that can always be used with
* `await`, etc. We therefore create a new promise in this realm that
* inherit the value and status from the given promise.
*/
function bring_promise_to_current_realm(promise) {
return new Promise(promise.then.bind(promise));
}
/**
* Assert that a Promise is rejected with the right ECMAScript exception.
*
* @param {Test} test - the `Test` to use for the assertion.
* @param {Function} constructor - The expected exception constructor.
* @param {Promise} promise - The promise that's expected to
* reject with the given exception.
* @param {string} [description] Error message to add to assert in case of
* failure.
*/
function promise_rejects_js(test, constructor, promise, description) {
return bring_promise_to_current_realm(promise)
.then(test.unreached_func("Should have rejected: " + description))
.catch(function(e) {
assert_throws_js_impl(constructor, function() { throw e },
description, "promise_rejects_js");
});
}
/**
* Assert that a Promise is rejected with the right DOMException.
*
* For the remaining arguments, there are two ways of calling
* promise_rejects_dom:
*
* 1) If the DOMException is expected to come from the current global, the
* third argument should be the promise expected to reject, and a fourth,
* optional, argument is the assertion description.
*
* 2) If the DOMException is expected to come from some other global, the
* third argument should be the DOMException constructor from that global,
* the fourth argument the promise expected to reject, and the fifth,
* optional, argument the assertion description.
*
* @param {Test} test - the `Test` to use for the assertion.
* @param {number|string} type - See documentation for
* `assert_throws_dom <#assert_throws_dom>`_.
* @param {Function} promiseOrConstructor - Either the constructor
* for the expected exception (if the exception comes from another
* global), or the promise that's expected to reject (if the
* exception comes from the current global).
* @param {Function|string} descriptionOrPromise - Either the
* promise that's expected to reject (if the exception comes from
* another global), or the optional description of the condition
* being tested (if the exception comes from the current global).
* @param {string} [description] - Description of the condition
* being tested (if the exception comes from another global).
*
*/
function promise_rejects_dom(test, type, promiseOrConstructor, descriptionOrPromise, maybeDescription) {
let constructor, promise, description;
if (typeof promiseOrConstructor === "function" &&
promiseOrConstructor.name === "DOMException") {
constructor = promiseOrConstructor;
promise = descriptionOrPromise;
description = maybeDescription;
} else {
constructor = self.DOMException;
promise = promiseOrConstructor;
description = descriptionOrPromise;
assert(maybeDescription === undefined,
"Too many args pased to no-constructor version of promise_rejects_dom");
}
return bring_promise_to_current_realm(promise)
.then(test.unreached_func("Should have rejected: " + description))
.catch(function(e) {
assert_throws_dom_impl(type, function() { throw e }, description,
"promise_rejects_dom", constructor);
});
}
/**
* Assert that a Promise is rejected with the provided value.
*
* @param {Test} test - the `Test` to use for the assertion.
* @param {Any} exception - The expected value of the rejected promise.
* @param {Promise} promise - The promise that's expected to
* reject.
* @param {string} [description] Error message to add to assert in case of
* failure.
*/
function promise_rejects_exactly(test, exception, promise, description) {
return bring_promise_to_current_realm(promise)
.then(test.unreached_func("Should have rejected: " + description))
.catch(function(e) {
assert_throws_exactly_impl(exception, function() { throw e },
description, "promise_rejects_exactly");
});
}
/**
* Allow DOM events to be handled using Promises.
*
* This can make it a lot easier to test a very specific series of events,
* including ensuring that unexpected events are not fired at any point.
*
* `EventWatcher` will assert if an event occurs while there is no `wait_for`
* created Promise waiting to be fulfilled, or if the event is of a different type
* to the type currently expected. This ensures that only the events that are
* expected occur, in the correct order, and with the correct timing.
*
* @constructor
* @param {Test} test - The `Test` to use for the assertion.
* @param {EventTarget} watchedNode - The target expected to receive the events.
* @param {string[]} eventTypes - List of events to watch for.
* @param {Promise} timeoutPromise - Promise that will cause the
* test to be set to `TIMEOUT` once fulfilled.
*
*/
function EventWatcher(test, watchedNode, eventTypes, timeoutPromise)
{
if (typeof eventTypes == 'string') {
eventTypes = [eventTypes];
}
var waitingFor = null;
// This is null unless we are recording all events, in which case it
// will be an Array object.
var recordedEvents = null;
var eventHandler = test.step_func(function(evt) {
assert_true(!!waitingFor,
'Not expecting event, but got ' + evt.type + ' event');
assert_equals(evt.type, waitingFor.types[0],
'Expected ' + waitingFor.types[0] + ' event, but got ' +
evt.type + ' event instead');
if (Array.isArray(recordedEvents)) {
recordedEvents.push(evt);
}
if (waitingFor.types.length > 1) {
// Pop first event from array
waitingFor.types.shift();
return;
}
// We need to null out waitingFor before calling the resolve function
// since the Promise's resolve handlers may call wait_for() which will
// need to set waitingFor.
var resolveFunc = waitingFor.resolve;
waitingFor = null;
// Likewise, we should reset the state of recordedEvents.
var result = recordedEvents || evt;
recordedEvents = null;
resolveFunc(result);
});
for (var i = 0; i < eventTypes.length; i++) {
watchedNode.addEventListener(eventTypes[i], eventHandler, false);
}
/**
* Returns a Promise that will resolve after the specified event or
* series of events has occurred.
*
* @param {Object} options An optional options object. If the 'record' property
* on this object has the value 'all', when the Promise
* returned by this function is resolved, *all* Event
* objects that were waited for will be returned as an
* array.
*
* @example
* const watcher = new EventWatcher(t, div, [ 'animationstart',
* 'animationiteration',
* 'animationend' ]);
* return watcher.wait_for([ 'animationstart', 'animationend' ],
* { record: 'all' }).then(evts => {
* assert_equals(evts[0].elapsedTime, 0.0);
* assert_equals(evts[1].elapsedTime, 2.0);
* });
*/
this.wait_for = function(types, options) {
if (waitingFor) {
return Promise.reject('Already waiting for an event or events');
}
if (typeof types == 'string') {
types = [types];
}
if (options && options.record && options.record === 'all') {
recordedEvents = [];
}
return new Promise(function(resolve, reject) {
var timeout = test.step_func(function() {
// If the timeout fires after the events have been received
// or during a subsequent call to wait_for, ignore it.
if (!waitingFor || waitingFor.resolve !== resolve)
return;
// This should always fail, otherwise we should have
// resolved the promise.
assert_true(waitingFor.types.length == 0,
'Timed out waiting for ' + waitingFor.types.join(', '));
var result = recordedEvents;
recordedEvents = null;
var resolveFunc = waitingFor.resolve;
waitingFor = null;
resolveFunc(result);
});
if (timeoutPromise) {
timeoutPromise().then(timeout);
}
waitingFor = {
types: types,
resolve: resolve,
reject: reject
};
});
};
/**
* Stop listening for events
*/
function stop_watching() {
for (var i = 0; i < eventTypes.length; i++) {
watchedNode.removeEventListener(eventTypes[i], eventHandler, false);
}
};
test._add_cleanup(stop_watching);
return this;
}
expose(EventWatcher, 'EventWatcher');
/**
* @typedef {Object} SettingsObject
* @property {bool} single_test - Use the single-page-test
* mode. In this mode the Document represents a single
* `async_test`. Asserts may be used directly without requiring
* `Test.step` or similar wrappers, and any exceptions set the
* status of the test rather than the status of the harness.
* @property {bool} allow_uncaught_exception - don't treat an
* uncaught exception as an error; needed when e.g. testing the
* `window.onerror` handler.
* @property {boolean} explicit_done - Wait for a call to `done()`
* before declaring all tests complete (this is always true for
* single-page tests).
* @property hide_test_state - hide the test state output while
* the test is running; This is helpful when the output of the test state
* may interfere the test results.
* @property {bool} explicit_timeout - disable file timeout; only
* stop waiting for results when the `timeout()` function is
* called This should typically only be set for manual tests, or
* by a test runner that providees its own timeout mechanism.
* @property {number} timeout_multiplier - Multiplier to apply to
* per-test timeouts. This should only be set by a test runner.
* @property {Document} output_document - The document to which
* results should be logged. By default this is the current
* document but could be an ancestor document in some cases e.g. a
* SVG test loaded in an HTML wrapper
*
*/
/**
* Configure the harness
*
* @param {Function|SettingsObject} funcOrProperties - Either a
* setup function to run, or a set of properties. If this is a
* function that function is run synchronously. Any exception in
* the function will set the overall harness status to `ERROR`.
* @param {SettingsObject} maybeProperties - An object containing
* the settings to use, if the first argument is a function.
*
*/
function setup(func_or_properties, maybe_properties)
{
var func = null;
var properties = {};
if (arguments.length === 2) {
func = func_or_properties;
properties = maybe_properties;
} else if (func_or_properties instanceof Function) {
func = func_or_properties;
} else {
properties = func_or_properties;
}
tests.setup(func, properties);
test_environment.on_new_harness_properties(properties);
}
/**
* Configure the harness, waiting for a promise to resolve
* before running any `promise_test` tests.
*
* @param {Function} func - Function returning a promise that's
* run synchronously. Promise tests are not run until after this
* function has resolved.
* @param {SettingsObject} [properties] - An object containing
* the harness settings to use.
*
*/
function promise_setup(func, properties={})
{
if (typeof func !== "function") {
tests.set_status(tests.status.ERROR,
"promise_test invoked without a function");
tests.complete();
return;
}
tests.promise_setup_called = true;
if (!tests.promise_tests) {
tests.promise_tests = Promise.resolve();
}
tests.promise_tests = tests.promise_tests
.then(function()
{
var result;
tests.setup(null, properties);
result = func();
test_environment.on_new_harness_properties(properties);
if (!result || typeof result.then !== "function") {
throw "Non-thenable returned by function passed to `promise_setup`";
}
return result;
})
.catch(function(e)
{
tests.set_status(tests.status.ERROR,
String(e),
e && e.stack);
tests.complete();
});
}
/**
* Mark test loading as complete.
*
* Typically this function is called implicitly on page load; it's
* only necessary for users to call this when either the
* ``explicit_done`` or ``single_page`` properties have been set
* via the :js:func:`setup` function.
*
* For single page tests this marks the test as complete and sets its status.
* For other tests, this marks test loading as complete, but doesn't affect ongoing tests.
*/
function done() {
if (tests.tests.length === 0) {
// `done` is invoked after handling uncaught exceptions, so if the
// harness status is already set, the corresponding message is more
// descriptive than the generic message defined here.
if (tests.status.status === null) {
tests.status.status = tests.status.ERROR;
tests.status.message = "done() was called without first defining any tests";
}
tests.complete();
return;
}
if (tests.file_is_test) {
// file is test files never have asynchronous cleanup logic,
// meaning the fully-synchronous `done` function can be used here.
tests.tests[0].done();
}
tests.end_wait();
}
/**
* @deprecated generate a list of tests from a function and list of arguments
*
* This is deprecated because it runs all the tests outside of the test functions
* and as a result any test throwing an exception will result in no tests being
* run. In almost all cases, you should simply call test within the loop you would
* use to generate the parameter list array.
*
* @param {Function} func - The function that will be called for each generated tests.
* @param {Any[][]} args - An array of arrays. Each nested array
* has the structure `[testName, ...testArgs]`. For each of these nested arrays
* array, a test is generated with name `testName` and test function equivalent to
* `func(..testArgs)`.
*/
function generate_tests(func, args, properties) {
forEach(args, function(x, i)
{
var name = x[0];
test(function()
{
func.apply(this, x.slice(1));
},
name,
Array.isArray(properties) ? properties[i] : properties);
});
}
/**
* @deprecated
*
* Register a function as a DOM event listener to the
* given object for the event bubbling phase.
*
* @param {EventTarget} object - Event target
* @param {string} event - Event name
* @param {Function} callback - Event handler.
*/
function on_event(object, event, callback)
{
object.addEventListener(event, callback, false);
}
/**
* Global version of :js:func:`Test.step_timeout` for use in single page tests.
*
* @param {Function} func - Function to run after the timeout
* @param {number} timeout - Time in ms to wait before running the
* test step. The actual wait time is ``timeout`` x
* ``timeout_multiplier``.
*/
function step_timeout(func, timeout) {
var outer_this = this;
var args = Array.prototype.slice.call(arguments, 2);
return setTimeout(function() {
func.apply(outer_this, args);
}, timeout * tests.timeout_multiplier);
}
expose(test, 'test');
expose(async_test, 'async_test');
expose(promise_test, 'promise_test');
expose(promise_rejects_js, 'promise_rejects_js');
expose(promise_rejects_dom, 'promise_rejects_dom');
expose(promise_rejects_exactly, 'promise_rejects_exactly');
expose(generate_tests, 'generate_tests');
expose(setup, 'setup');
expose(promise_setup, 'promise_setup');
expose(done, 'done');
expose(on_event, 'on_event');
expose(step_timeout, 'step_timeout');
/*
* Return a string truncated to the given length, with ... added at the end
* if it was longer.
*/
function truncate(s, len)
{
if (s.length > len) {
return s.substring(0, len - 3) + "...";
}
return s;
}
/*
* Return true if object is probably a Node object.
*/
function is_node(object)
{
// I use duck-typing instead of instanceof, because
// instanceof doesn't work if the node is from another window (like an
// iframe's contentWindow):
// http://www.w3.org/Bugs/Public/show_bug.cgi?id=12295
try {
var has_node_properties = ("nodeType" in object &&
"nodeName" in object &&