diff --git a/demos/qunit-onerror-early.html b/demos/qunit-onerror-early.html
new file mode 100644
index 000000000..531fbe56b
--- /dev/null
+++ b/demos/qunit-onerror-early.html
@@ -0,0 +1,26 @@
+
+
+
+
+ window-onerror-early
+
+
+
+
+
+
+
+
+
diff --git a/src/core/callbacks.js b/src/core/callbacks.js
index 855280ec2..d8659051f 100644
--- a/src/core/callbacks.js
+++ b/src/core/callbacks.js
@@ -1,4 +1,5 @@
import config from './config.js';
+import { prioritySymbol } from './events.js';
import Promise from './promise.js';
export function createRegisterCallbackFunction (key) {
@@ -7,11 +8,19 @@ export function createRegisterCallbackFunction (key) {
config.callbacks[key] = [];
}
- return function registerCallback (callback) {
+ return function registerCallback (callback, priority = null) {
if (typeof callback !== 'function') {
throw new TypeError('Callback parameter must be a function');
}
- config.callbacks[key].push(callback);
+ /* istanbul ignore if: internal argument */
+ if (priority && priority !== prioritySymbol) {
+ throw new TypeError('invalid priority parameter');
+ }
+ if (priority === prioritySymbol) {
+ config.callbacks[key].unshift(callback);
+ } else {
+ config.callbacks[key].push(callback);
+ }
};
}
diff --git a/src/core/events.js b/src/core/events.js
index 544f2b81d..f4ed28c04 100644
--- a/src/core/events.js
+++ b/src/core/events.js
@@ -12,6 +12,7 @@ const SUPPORTED_EVENTS = [
'runEnd'
];
const MEMORY_EVENTS = [
+ 'error',
'runEnd'
];
@@ -41,7 +42,7 @@ export function emit (eventName, data) {
callbacks[i](data);
}
- if (inArray(MEMORY_EVENTS, eventName)) {
+ if (inArray(eventName, MEMORY_EVENTS)) {
config._event_memory[eventName] = data;
}
}
@@ -69,6 +70,7 @@ export function on (eventName, callback, priority = null) {
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function when registering a listener');
}
+ /* istanbul ignore if: internal argument */
if (priority && priority !== prioritySymbol) {
throw new TypeError('invalid priority parameter');
}
diff --git a/src/core/reporters/HtmlReporter.js b/src/core/reporters/HtmlReporter.js
index da647067f..9215fadb0 100644
--- a/src/core/reporters/HtmlReporter.js
+++ b/src/core/reporters/HtmlReporter.js
@@ -187,18 +187,25 @@ export default class HtmlReporter {
});
this.dropdownData = null;
+ // We must not fallback to creating `` ourselves if it
+ // does not exist, because not having id="qunit" is how projects indicate
+ // that they wish to run QUnit headless, with their own reporters.
this.element = options.element || undefined;
this.elementBanner = null;
this.elementDisplay = null;
this.elementTests = null;
- // TODO: Consider rendering the UI early when possible for improved UX.
- // NOTE: Only listen for "error" and "runStart" now.
+ // NOTE: Only listen for "runStart" now.
// Other event handlers are added via listen() from onRunStart,
// after we know that the element exists. This reduces overhead and avoids
// potential internal errors when the HTML Reporter is disabled.
this.listen = function () {
this.listen = null;
+ // Use prioritySignal for begin() to ensure the UI shows up
+ // reliably to render errors from onError.
+ // Without this, user-defined "QUnit.begin()" callbacks will end
+ // up in the queue before ours, and if those throw an error,
+ // then this handler will never run, thus leaving the page blank.
QUnit.begin(this.onBegin.bind(this), prioritySymbol);
// Use prioritySignal for testStart() to increase availability
// of the HTML API for TESTID elements toward other event listeners.
@@ -207,7 +214,10 @@ export default class HtmlReporter {
QUnit.testDone(this.onTestDone.bind(this));
QUnit.on('runEnd', this.onRunEnd.bind(this));
};
- QUnit.on('error', this.onError.bind(this), prioritySymbol);
+ this.listenError = function () {
+ this.listenError = null;
+ QUnit.on('error', this.onError.bind(this), prioritySymbol);
+ };
QUnit.on('runStart', this.onRunStart.bind(this), prioritySymbol);
}
@@ -712,6 +722,16 @@ export default class HtmlReporter {
onBegin (beginDetails) {
this.appendInterface(beginDetails);
this.elementDisplay.className = 'running';
+
+ // TODO: We render the UI late here from onBegin because the toolbar and module dropdown
+ // rely on user-defined information. If we refactor the UI to render (most of it) early,
+ // from the constructor if possible, with last retry during runStart, then we can move
+ // this listener to the listen() function. We place it here so that onError can safely
+ // call appendTest() and rely on `this.element` and `this.elementTests` being set.
+ // We could alternatively set up the "error" listener in the constructor already, and
+ // have an "early error" buffer for errors before the UI is ready, but we instead rely
+ // on QUnit.on's built-in memory for the "error" event.
+ this.listenError();
}
getRerunFailedHtml (failedTests) {
@@ -999,15 +1019,7 @@ export default class HtmlReporter {
}
onError (error) {
- const testItem = this.elementTests && this.appendTest('global failure');
- if (!testItem) {
- // HTML Reporter is probably disabled or not yet initialized.
- // This kind of early error will be visible in the browser console
- // and via window.onerror, but we can't show it in the UI.
- //
- // TODO: Consider stashing early error here and replay in UI during onRunStart.
- return;
- }
+ const testItem = this.appendTest('global failure');
// Render similar to a failed assertion (see above QUnit.log callback)
let message = escapeText(errorString(error));
@@ -1025,7 +1037,8 @@ export default class HtmlReporter {
assertList.appendChild(assertLi);
// Make it visible
- testItem.className = 'fail';
+ DOM.removeClass(testItem, 'running');
+ DOM.addClass(testItem, 'fail');
}
}
diff --git a/test/main/HtmlReporter.js b/test/main/HtmlReporter.js
index 643e94311..1c4fea909 100644
--- a/test/main/HtmlReporter.js
+++ b/test/main/HtmlReporter.js
@@ -38,11 +38,14 @@ QUnit.module.if('HtmlReporter', typeof document !== 'undefined', {
this.emit('runStart', { testCounts: { total: 0 } });
this.emit('begin', { modules: [] });
},
- // The first 1.5 test.
- _do_mixed_run_half: function () {
+ // The first 1 test.
+ _do_start_with_one: function () {
this.emit('runStart', { testCounts: { total: 4 } });
this.emit('begin', { modules: [] });
+ this._do_one_test();
+ },
+ _do_one_test: function () {
this.emit('testStart', { testId: '00A', name: 'A' });
this.emit('log', {
testId: '00A',
@@ -50,6 +53,10 @@ QUnit.module.if('HtmlReporter', typeof document !== 'undefined', {
runtime: 0
});
this.emit('testDone', { testId: '00A', name: 'A', total: 1, passed: 1, failed: 0, runtime: 0 });
+ },
+ // The first 1.5 test.
+ _do_mixed_run_half: function () {
+ this._do_start_with_one();
this.emit('testStart', { testId: '00B', name: 'B' });
this.emit('log', {
@@ -92,6 +99,11 @@ QUnit.module.if('HtmlReporter', typeof document !== 'undefined', {
});
}
};
+ },
+ afterEach: function () {
+ if (this.restoreQUnitElement) {
+ this.restoreQUnitElement.id = 'qunit';
+ }
}
});
@@ -223,6 +235,74 @@ QUnit.test('test-output [trace]', function (assert) {
);
});
+QUnit.test('onError [mid-run]', function (assert) {
+ var element = document.createElement('div');
+ new QUnit.reporters.html(this.MockQUnit, {
+ element: element,
+ config: {
+ urlConfig: []
+ }
+ });
+ this.MockQUnit._do_start_with_one();
+ var err = new Error('boo');
+ err.stack = 'bar@example.js\nfoo@example.js\n@foo.test.js';
+ this.MockQUnit.emit('error', err);
+
+ var testItem = element.querySelector('#qunit-test-output-00A');
+ assert.strictEqual(
+ testItem.textContent,
+ 'A (1)' + 'Rerun' + '0 ms' +
+ 'okay' + '@ 0 ms',
+ 'last test item (unchanged)'
+ );
+
+ // last child
+ var errorItem = element.querySelector('#qunit-tests > li:last-child');
+ assert.strictEqual(errorItem.id, '', 'error item, ID');
+ assert.strictEqual(
+ errorItem.textContent,
+ 'global failure' +
+ 'Error: boo' +
+ 'Source: bar@example.js\nfoo@example.js\n@foo.test.js',
+ 'error item, text'
+ );
+});
+
+QUnit.test('onError [early]', function (assert) {
+ var element = document.createElement('div');
+ new QUnit.reporters.html(this.MockQUnit, {
+ element: element,
+ config: {
+ urlConfig: []
+ }
+ });
+ var err = new Error('boo');
+ err.stack = 'bar@example.js\nfoo@example.js\n@foo.test.js';
+
+ this.MockQUnit._do_start_empty();
+ this.MockQUnit.emit('error', err);
+ this.MockQUnit._do_one_test();
+
+ var testItem = element.querySelector('#qunit-test-output-00A');
+ assert.strictEqual(
+ testItem.textContent,
+ 'A (1)' + 'Rerun' + '0 ms' +
+ 'okay' + '@ 0 ms',
+ 'last test item (unchanged)'
+ );
+
+ // first child
+ var errorItem = element.querySelector('#qunit-tests > li:first-child');
+ assert.strictEqual(errorItem.id, '', 'error item, ID');
+ assert.strictEqual(
+ errorItem.textContent,
+ 'global failure' +
+ 'Error: boo' +
+ 'Source: bar@example.js\nfoo@example.js\n@foo.test.js',
+ 'error item, text'
+ );
+});
+
QUnit.test('appendFilteredTest()', function (assert) {
var element = document.createElement('div');
new QUnit.reporters.html(this.MockQUnit, {
@@ -322,7 +402,7 @@ QUnit.test('options [urlConfig]', function (assert) {
));
});
-QUnit.test('disable [via options.element=null]', function (assert) {
+QUnit.test('listen [disable via options.element=null]', function (assert) {
this.MockQUnit.on = function (type) {
assert.step('listen on-' + type);
};
@@ -336,7 +416,74 @@ QUnit.test('disable [via options.element=null]', function (assert) {
});
this.MockQUnit._do_mixed_run_full();
- assert.verifySteps([], 'zero listeners when disabled');
+ assert.verifySteps([], 'listeners');
+});
+
+QUnit.test('listen [disable via no qunit element]', function (assert) {
+ // Temporarily hide the global #qunit element
+ var globalElement = document.querySelector('#qunit');
+ if (globalElement) {
+ globalElement.id = 'not-qunit';
+ this.restoreQUnitElement = globalElement;
+ }
+
+ this.MockQUnit.on = function (type) {
+ assert.step('listen on-' + type);
+ };
+ this.MockQUnit.emit = function () {};
+
+ new QUnit.reporters.html(this.MockQUnit, {
+ config: {
+ urlConfig: []
+ }
+ });
+ this.MockQUnit._do_mixed_run_full();
+
+ var newElement = document.querySelector('#qunit');
+ assert.strictEqual(newElement, null, 'no automatic element created');
+
+ assert.verifySteps(
+ [
+ 'listen on-runStart'
+ ],
+ 'listeners'
+ );
+});
+
+QUnit.test('listen [enable via options.element]', function (assert) {
+ this.MockQUnit.on = function (event, fn) {
+ assert.step('listen on-' + event);
+ this.on[event] = fn;
+ };
+
+ var element = document.createElement('div');
+ new QUnit.reporters.html(this.MockQUnit, {
+ element: element,
+ config: {
+ urlConfig: []
+ }
+ });
+
+ assert.verifySteps(
+ [
+ 'listen on-runStart'
+ ],
+ 'listeners after construction'
+ );
+
+ this.MockQUnit._do_start_empty();
+
+ assert.verifySteps(
+ [
+ 'listen on-begin',
+ 'listen on-testStart',
+ 'listen on-log',
+ 'listen on-testDone',
+ 'listen on-runEnd',
+ 'listen on-error'
+ ],
+ 'listeners after start'
+ );
});
QUnit.test('module selector', function (assert) {