Skip to content

Commit

Permalink
HTML Reporter: Add support for displaying early errors
Browse files Browse the repository at this point in the history
  • Loading branch information
Krinkle committed Jul 23, 2024
1 parent 05e15ba commit b1ec2a4
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 11 deletions.
26 changes: 26 additions & 0 deletions demos/qunit-onerror-early.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>window-onerror-early</title>
<link rel="stylesheet" href="../src/core/qunit.css">
<script src="../qunit/qunit.js"></script>
<script>
QUnit.begin(function () {
// eslint-disable-next-line no-undef
beginBoom();
});

// eslint-disable-next-line no-undef
outerBoom();
</script>
<script>
QUnit.test('example', function (assert) {
assert.true(true);
});
</script>
</head>
<body>
<div id="qunit"></div>
</body>
</html>
13 changes: 11 additions & 2 deletions src/core/callbacks.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import config from './config.js';
import { prioritySymbol } from './events.js';
import Promise from './promise.js';

export function createRegisterCallbackFunction (key) {
Expand All @@ -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);
}
};
}

Expand Down
4 changes: 3 additions & 1 deletion src/core/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const SUPPORTED_EVENTS = [
'runEnd'
];
const MEMORY_EVENTS = [
'error',
'runEnd'
];

Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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');
}
Expand Down
32 changes: 27 additions & 5 deletions src/core/reporters/HtmlReporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,19 +186,27 @@ export default class HtmlReporter {
testId: undefined
});
this.dropdownData = null;
this.earlyError = null;

// We must not fallback to creating `<div id="qunit">` 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 "error", "runStart", and "begin" 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.
Expand Down Expand Up @@ -710,8 +718,16 @@ export default class HtmlReporter {
// add entries to QUnit.config.urlConfig, which may be done asynchronously.
// https://github.com/qunitjs/qunit/issues/1657
onBegin (beginDetails) {
if (!this.element) {
return;
}
this.appendInterface(beginDetails);
this.elementDisplay.className = 'running';

if (this.earlyError) {
this.onError(this.earlyError);
this.earlyError = null;
}
}

getRerunFailedHtml (failedTests) {
Expand Down Expand Up @@ -1001,11 +1017,16 @@ export default class HtmlReporter {
onError (error) {
const testItem = this.elementTests && this.appendTest('global failure');
if (!testItem) {
// onError(), onRunStart(), and begin() are the only methods where
// this.element is expected to sometimes be unset.
//
// 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.
// and via window.onerror, but we can't show it in the UI yet.
//
// TODO: Consider stashing early error here and replay in UI during onRunStart.
// If `<script src="qunit.js'>` is placed before `<div id="qunit">`,
// we'll try again later in onRunStart after this.element is set.
this.earlyError = error;
return;
}

Expand All @@ -1025,7 +1046,8 @@ export default class HtmlReporter {
assertList.appendChild(assertLi);

// Make it visible
testItem.className = 'fail';
DOM.removeClass(testItem, 'running');
DOM.addClass(testItem, 'fail');
}
}

Expand Down
112 changes: 109 additions & 3 deletions test/main/HtmlReporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ 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: [] });

Expand All @@ -50,6 +50,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', {
Expand Down Expand Up @@ -92,6 +96,11 @@ QUnit.module.if('HtmlReporter', typeof document !== 'undefined', {
});
}
};
},
afterEach: function () {
if (this.restoreQUnitElement) {
this.restoreQUnitElement.id = 'qunit';
}
}
});

Expand Down Expand Up @@ -223,6 +232,72 @@ 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 = '[email protected]\[email protected]\[email protected]';
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: [email protected]\[email protected]\[email protected]',
'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 = '[email protected]\[email protected]\[email protected]';
this.MockQUnit.emit('error', err);
this.MockQUnit._do_start_with_one();

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: [email protected]\[email protected]\[email protected]',
'error item, text'
);
});

QUnit.test('appendFilteredTest()', function (assert) {
var element = document.createElement('div');
new QUnit.reporters.html(this.MockQUnit, {
Expand Down Expand Up @@ -336,7 +411,38 @@ QUnit.test('disable [via options.element=null]', function (assert) {
});
this.MockQUnit._do_mixed_run_full();

assert.verifySteps([], 'zero listeners when disabled');
assert.verifySteps([], 'zero listeners');
});

QUnit.test('disable [via default options and 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-error',
'listen on-runStart'
],
'listeners limited to early one-off events');
});

QUnit.test('module selector', function (assert) {
Expand Down

0 comments on commit b1ec2a4

Please sign in to comment.