testing: Add minimal javascript testing framework

* Vendor the test-tap library
* Set up a browser harness
* Add some tests to the test suite
This commit is contained in:
Jeremy Wall 2024-04-07 16:25:08 -04:00
parent c12c7c2477
commit a84326cb67
5 changed files with 579 additions and 0 deletions

View File

@ -271,6 +271,10 @@ export class GraphPlot extends HTMLElement {
return name;
}
getFilterLabels() {
return this.#filterLabels;
}
/**
* @param {Object<string, string>} labels
*/

454
tests/TAP.mjs Normal file
View File

@ -0,0 +1,454 @@
/**
* Tap - a 0 dependency TAP compliant test library useable from the commandline
*
* ```js
* import { Tap } from './src/TAP.mjs';
* var t = new Tap;
* t.plan(3);
*
* t.ok(true, 'True is True'); # test will pass
* t.is(1, 2, 'one is two'); # test will fail
*
* var obj = {};
* obj.method1 = function() { return true; };
*
* t.can_ok(obj, 'method1'); # test will pass
* ```
*
* @module TAP
* @license Artistic-2.0
*/
/** @implements TapRenderer */
class NodeRenderer {
/** @type {Array<PromiseLike>} */
#thunks = [];
out(text) {
this.#thunks.push(
// Because this is a ECMAScript module we have to do dynamic module loads
// of the node ecosystem when running in Node.js.
import('node:process').then(loaded => {
loaded.stdout.write(text + "\n");
}));
}
comment(lines) {
for (var line of lines) {
this.out('# ' + line);
}
}
// This gives us a way to block on output. It's ghetto but async is a harsh task master.
async renderAll() {
for (var thunk of this.#thunks) {
await thunk;
}
}
}
/** @implements TapRenderer */
class BrowserRenderer {
#target = document.body;
/** @param {HtmlElement=} target */
constructor(target) {
if (target) {
this.#target = target;
}
}
/** @returns TextNode */
#createText(text) {
return document.createTextNode(text);
}
/**
* @param {Node} nodes
* @returns HTMLDivElement
*/
#createDiv(nodes) {
const div = document.createElement("div");
div.append(...nodes);
return div;
}
out(text) {
const textNode = this.#createText(text);
var div = this.#createDiv([textNode]);
if (text.startsWith("not ok")) {
div.setAttribute("class", "fail");
} else if (text.startsWith("ok")) {
div.setAttribute("class", "pass");
}
this.#target.appendChild(div);
}
comment(lines) {
// TODO(jeremy):
var elems = [];
for (var line of lines) {
elems.push(this.#createText("# " + line), document.createElement("br"));
}
var commentDiv = this.#createDiv(elems);
commentDiv.setAttribute("class", "comment");
this.#target.appendChild(commentDiv);
}
}
/**
* The Tap Test Class helper.
*/
class Tap {
/** @type Number? */
planned = null;
/** @type Number */
counter = 0;
/** @type Number */
passed = 0;
/** @type Number */
failed = 0;
/** @type TapRenderer */
renderer
/**
* Construct a new Tap Suite with a renderLine function.
* @param {TapRenderer}
*/
constructor(renderer) {
this.renderer = renderer;
}
/**
* @return {{"Renderer": BrowserRenderer, "Tap": Tap}}
*/
static Browser() {
var r = new BrowserRenderer();
return {"Renderer": r, "Tap": new Tap(r)};
return new Tap(new BrowserRenderer());
}
/**
* @return {{"Renderer": NodeRenderer, "Tap": Tap}}
*/
static Node() {
var r = new NodeRenderer();
return {"Renderer": r, "Tap": new Tap(r)};
}
isPass() {
return this.passed != 0;
}
/** Renders output for the test results */
out(text) {
this.renderer.out(text);
};
/**
* Construct a Tap output message.
*
* @param {boolean} ok
* @param {string=} description
*/
mk_tap(ok, description) {
if (!this.planned) {
this.out("You tried to run tests without a plan. Gotta have a plan.");
throw new Error("You tried to run tests without a plan. Gotta have a plan.");
}
this.counter++;
this.out(ok + ' ' + this.counter + ' - ' + (description || ""));
};
comment(msg) {
if (!msg) {
msg = " ";
}
var lines = msg.split("\n");
this.renderer.comment(lines);
};
/** Render a pass TAP output message.
* @param {string} description
*/
pass(description) {
this.passed++;
this.mk_tap('ok', description);
};
/** Render a fail TAP output message.
* @param {string} description
*/
fail(description) {
this.failed++;
this.mk_tap('not ok', description);
};
/** Run a function as a TODO test.
*
* @param {function(this:Tap, boolean, description)} func
*/
todo(func) {
var self = this;
var tapper = self.mk_tap;
self.mk_tap = function(ok, desc) {
tapper.apply(self, [ok, "# TODO: " + desc]);
}
func();
self.mk_tap = tapper;
}
/** Run a function as a skip Test.
*
* @param {boolean} criteria
* @param {string} reason
* @param {number} count - The number of tests to skip
* @param {function(this:Tap, boolean, description)} func
*/
skip(criteria, reason, count, func) {
var self = this;
if (criteria) {
var tapper = self.mk_tap;
self.mk_tap = function(ok, desc) {
tapper.apply(self, [ok, desc]);
}
for (var i = 0; i < count; i++) {
self.fail("# SKIP " + reason)
}
self.mk_tap = tapper;
} else {
func();
}
}
/** Sets the test plan.
* Once set this can not be reset again. Any attempt to change the plan once already
* set will throw an exception.
*
* Call with no arguments if you don't want to specify the number of tests to run.
*
* @param {Number=} testCount
*/
plan(testCount) {
if (this.planned) {
throw new Error("you tried to set the plan twice!");
}
if (!testCount) {
this.planned = 'no_plan';
} else {
this.planned = testCount;
this.out('1..' + testCount);
}
};
#pass_if(func, desc) {
var result = func();
if (result) { this.pass(desc) }
else { this.fail(desc) }
}
// exception tests
/**
* Tests that a function throws with a given error message.
*
* @param {function()} func
* @param {RegExp} msg
*/
throws_ok(func, msg) {
var errormsg = ' ';
if (typeof func != 'function')
this.comment('throws_ok needs a function to run');
try {
func();
}
catch (err) {
errormsg = err + '';
}
this.like(errormsg, msg, 'code threw [' + errormsg + '] expected: [' + msg + ']');
}
/**
* Tests that a function throws.
*
* @param {function()} func
*/
dies_ok(func) {
var errormsg = ' ';
var msg = false;
if (typeof func != 'function')
this.comment('throws_ok needs a function to run');
try {
func();
}
catch (err) {
errormsg = err + '';
msg = true;
}
this.ok(msg, 'code died with [' + errormsg + ']');
}
/**
* Tests that a function does not throw an exception.
*
* @param {function()} func
*/
lives_ok(func, msg) {
var errormsg = true;
if (typeof func != 'function')
this.comment('throws_ok needs a function to run');
try {
func();
}
catch (err) {
errormsg = false;
}
this.ok(errormsg, msg);
}
/**
* Tests that an object has a given method or function.
*
* @param {*} obj
*/
can_ok(obj) {
var desc = 'object can [';
var pass = true;
for (var i = 1; i < arguments.length; i++) {
if (typeof (obj[arguments[i]]) != 'function') {
if (typeof (obj.prototype) != 'undefined') {
var result = typeof eval('obj.prototype.' + arguments[i]);
if (result == 'undefined') {
pass = false;
this.comment('Missing ' + arguments[i] + ' method');
}
} else {
pass = false;
this.comment('Missing ' + arguments[i] + ' method');
}
}
desc += ' ' + arguments[i];
}
desc += ' ]';
this.#pass_if(function() {
return pass;
}, desc);
}
/**
* Tests that two given objects are equal.
*
* @param {*} got
* @param {*} expected
* @param {string} desc
*/
is(got, expected, desc) {
this.#pass_if(function() { return got == expected; }, desc);
};
/**
* Test that expression evaluates to true value
*
* @param {*} got
* @param {string} desc
*/
ok(got, desc) {
this.#pass_if(function() { return !!got; }, desc);
};
/**
* Tests that a string matches the regex.
*
* @param {string} string
* @param {RegExp} regex
* @param {string} desc
*/
like(string, regex, desc) {
this.#pass_if(function() {
if (regex instanceof RegExp) {
return string.match(regex)
} else {
return string.indexOf(regex) != -1
}
}, desc)
}
/**
* The opposite of like. tests that the string doesn't match the regex.
*
* @param {string} string
* @param {RegExp} regex
* @param {string} desc
*/
unlike(string, regex, desc) {
this.#pass_if(function() {
return !string.match(regex);
}, desc)
}
}
/**
* Run a test and render a summarization.
*
* @param {Tap} t
* @param {string} testName
* @param {function(Tap)} test
*/
function runTest(t, testName, test) {
t.comment('running ' + testName + ' tests');
try {
test(t);
summarize(t);
}
catch (err) {
t.comment("Test Suite Crashed!!! (" + err + ")");
}
return t;
}
/**
* Output a summary of the tests as comments.
*
* @param {Tap} t
*/
function summarize(t) {
if (t.planned > t.counter) {
t.comment('looks like you planned ' + t.planned + ' tests but only ran '
+ t.counter + ' tests');
} else if (t.planned < t.counter) {
t.comment('looks like you planned ' + t.planned + ' tests but ran '
+ (t.counter - t.planned) + ' tests extra');
}
t.comment('ran ' + t.counter + ' tests out of ' + t.planned);
t.comment('passed ' + t.passed + ' tests out of ' + t.planned);
t.comment('failed ' + t.failed + ' tests out of ' + t.planned);
}
/**
* @param {Tap} t
* @param {Array<{'plan': Number, name: string, 'test': function(Tap)}} suite
*/
function runSuite(t, suite) {
const plan = suite.reduce((acc, item) => {
return acc + item.plan
}, 0);
t.plan(plan);
for (var item of suite) {
t.comment('running ' + item.name + ' tests');
item.test(t);
}
summarize(t);
}
export { Tap, runTest, runSuite, summarize, BrowserRenderer, NodeRenderer };

20
tests/Tap.d.js Normal file
View File

@ -0,0 +1,20 @@
/** @interface */
class TapRenderer {
/** Renders output for the test results
* @param {string} text
*/
out(text) {
}
/** Diagnostic formatter for TAP Output.
*
* @param {Array<string>} lines
*/
comment(lines) {
}
}
/**
* @typedef TapSuite
* @type {Array<{'plan': Number, name: string, 'test': function(Tap)}}
*/

44
tests/TapHarness.html Normal file
View File

@ -0,0 +1,44 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<style type="text/css">
.test {
margin-top : 10px;
margin-bottom : 10px;
border : 3px;
border-style : inset;
overflow : auto;
}
.small {
height : 20px;
}
.big {
height : 600px;
}
.comment { color: darkgray; }
.pass { color: green; }
.fail { color: red; }
</style>
<script type="text/javascript">
/** Configuration options
*
*/
var tests = [
'01_tap.t.js',
];
</script>
Heracles custom elements test harness.
<script type="module">
import { Tap, runSuite } from './TAP.mjs';
import { tapSuite } from './suite.mjs';
const pair = Tap.Browser();
runSuite(pair.Tap, tapSuite);
</script>
</body>
</html>

57
tests/suite.mjs Normal file
View File

@ -0,0 +1,57 @@
// TODO(jwall): Figure out how to handle the missing browser apis in node contexts.
import { GraphPlot, SpanSelector } from '../static/lib.mjs';
function deepEqual(got, expected) {
// Check if both are the same reference or both are null
if (got === expected) { return true; }
// Check if both are objects (including arrays) and neither is null
if (typeof got !== 'object' || got === null ||
typeof expected !== 'object' || expected === null) {
return false;
}
// Get the keys of both objects
const keysGot = Object.keys(got), keysExpected = Object.keys(expected);
// If number of properties is different, objects are not equivalent
if (keysGot.length !== keysExpected.length) { return false; }
// Check all properties of a are in b and are equal
for (let key of keysGot) {
if (!keysExpected.includes(key) || !deepEqual(got[key], expected[key])) { return false; }
}
// If we got this far, objects are considered equivalent
return true;
}
/**
* @type {TapSuite}
*/
export var tapSuite = [
{
plan: 2,
name: "Custom Element registration Tests",
test: function(t) {
t.ok(customElements.get(GraphPlot.elementName), `GraphPlot element is registered with name ${GraphPlot.elementName}`);
t.ok(customElements.get(SpanSelector.elementName), `SpanSelector element is registered with name ${SpanSelector.elementName}`);
}
},
{
plan: 5,
name: "PopulateFilterData test",
test: function(t) {
const plot = new GraphPlot();
t.ok(deepEqual(plot.getFilterLabels(), {}), "filter lables start out empty");
plot.populateFilterData({});
t.ok(typeof(deepEqual(plot.getFilterLabels()), {}), "filter lables are still empty");
plot.populateFilterData({"foo": "bar"});
t.ok(deepEqual(plot.getFilterLabels(), {"foo": ["bar"]}), "filter labels get set with list of one item on first label");
plot.populateFilterData({"foo": "quux"});
t.ok(deepEqual(plot.getFilterLabels(), {"foo": ["bar", "quux"]}), "list of two values after second label value");
plot.populateFilterData({"foo": "bar"});
t.ok(deepEqual(plot.getFilterLabels(), {"foo": ["bar", "quux"]}), "We don't double add the same value");
}
}
];