UNPKG

test-kit

Version:

An improved data-driven test experience using tap or tape

499 lines (445 loc) 16.4 kB
// Software License Agreement (ISC License) // // Copyright (c) 2021, Matthew Voss // // Permission to use, copy, modify, and/or distribute this software for // any purpose with or without fee is hereby granted, provided that the // above copyright notice and this permission notice appear in all copies. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR // ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. var assign = require('qb-assign') var jstr = require('qb-js-string') // collects test argument and executes them later (on timeout) according to whether 'only' // was called or not. function TestRunner (test_module, test_fn, enrich_fns) { this.inputs = [] // array of { args: [...], tk_props: { ... } } this.only_called = false this.running = false this.test_module = test_module this.test_fn = test_fn this.enrich_fns = assign({}, enrich_fns) } TestRunner.prototype = { constructor: TestRunner, run: function () { var self = this setTimeout(function () { self.running = true self.inputs.forEach(function (input) { self.test_fn.apply(self.test_module, enrich_test_arguments(input.args, self.enrich_fns, input.tk_props)) }) }) }, addTest: function (args, tk_props) { if (this.running) { throw Error('cannot add test - already running') } var input = { args: args, tk_props: tk_props } if (tk_props.only) { if (this.only_called) { throw Error('there can only be one only test') } this.only_called = true this.inputs = [input] } else { if (!this.only_called) { this.inputs.push(input) } } } } // given the arguments to tap.test(...), alter the first function to return an enriched 't' object as in: // // test('mytest', function(t) {...} ) // // tk_props (optional) is added to the enriched function as '_tk_props' - for use by other functions (test modes) // function enrich_test_arguments (args, enrich_fns, tk_props) { args = Array.prototype.slice.call(args) var fi = args.findIndex(function (a) { return typeof (a) === 'function' }) args[fi] = enrich_t(args[fi], enrich_fns, tk_props) return args } // enrich the test or 't' object by applying transforms in enrich_fns // 'fn' is the user function that we will call with the new enriched 't': fn(t) function enrich_t (fn, enrich_fns, tk_props) { return function (torig) { var tnew = Object.create(torig) Object.keys(enrich_fns).forEach(function (n) { tnew[n] = enrich_fns[n](torig, tnew) }) tnew.tk_props = tk_props fn(tnew) } } // Return a string loosely based on JSON.stringify, but with single quotes and fewer escapes. // (less precise, more readable) // // instead of: // // ok 1 - error: ("d--","/","a/b") -expect-> ("expect: parent \"a\" is not a directory") // // str() returns: // // ok 1 - error: ('d--','/','a/b') -expect-> ('expect: parent "a" is not a directory') // // str() converts undefined to null and doesn't handle cycles, so has room for improvement. function str (v) { if (v === undefined) return 'null' return JSON.stringify(v, replacer).replace(/([^\\])"/g, "$1'").replace(/\\"/g, '"').replace(/\\\\/g, '\\') } function replacer (ignore, v) { if(typeof v === 'function') { v = (v.name && v.name + ' ()') || 'function ()' } return v } function parens (args) { var ret = str(args) return '(' + ret.substr(1, ret.length - 2) + ')' } function last (a) { return a[a.length - 1] } function text_lines (s) { var lines = s.split('\n') for (var beg = 0; beg < lines.length && /^\s*$/.test(lines[beg]); beg++); for (var end = lines.length - 1; end >= 0 && /^\s*$/.test(lines[end]); end--); if (beg > end) { return [] } var ind = countws(lines[beg]) for (var i = beg; i <= end; i++) { var line = lines[i] var ws = countws(line, ind) lines[i] = ws === line.length ? '' : line.substring(Math.min(ws, ind)) } return lines.slice(beg, end + 1) } function countws (s) { for (var c = 0; c < s.length && s[c] === ' '; c++); return c } function padl (s, l, c) { c = c || ' '; while (s.length < l) s = c + s; return s } function padr (s, l, c) { c = c || ' '; while (s.length < l) s = s + c; return s } function trunc (a) { var i = a.length-1; while (a[i] == null && i >= 0) i--; return Array.prototype.slice.call(a, 0, i+1) } function sum (a, prop_or_func) { if (prop_or_func == null) { return a.reduce(function (s, v) { return s + (v || 0) }, 0) } else if (typeof prop_or_func === 'function') { return a.reduce(function (s, v) { return s + prop_or_func(v) }, 0) } else { return a.reduce(function (s, v) { return s + (v[prop_or_func] || 0) }, 0) } } function table (data) { return require('test-table').create(data) } function table_assert (torig, tnew) { return function (dataOrTable, fn, opt) { fn && typeof fn === 'function' || err('invalid function argument: ' + fn) opt = assign({}, {assert: 'same'}, opt) var tbl = tnew.table(dataOrTable) var tp = tnew.tk_props if (tp.trows) { tbl = tbl.trows.apply(tbl, tp.trows) } if (tp.print_mode) { print_table(tnew, tbl, fn, opt, tp.print_mode) } else { assert_table(tnew, tbl, fn, opt) } } } function assert_table(tnew, tbl, fn, opt) { if (opt.plan == null) { opt.plan = (!tnew.planned_tests && opt.assert !== 'none') ? 1 : 0 } else { opt.plan === 0 || !tnew.planned_tests || err('plan has already been set: ' + tnew.planned_tests) } if (opt.plan) { // non-zero var plan_total if (typeof opt.plan === 'string') { plan_total = tnew.sum(tbl.vals(opt.plan)) } else { plan_total = tbl.length * opt.plan } tnew.plan(plan_total) // sets planned_tests, which cannot be changed } tbl.rows.forEach(function (r) { if (r._comments.length) { r._comments.forEach(function (c) { console.log(c) }) } var vals = r._vals() var exp_val if (opt.assert === 'none') { if (opt.trunc) { vals = tnew.trunc(vals) } fn.apply(null, vals) } else { vals = vals.slice() exp_val = vals.pop() if (opt.trunc) { vals = tnew.trunc(vals) } if (opt.assert === 'throws') { tnew.throws(function () { fn.apply(null, vals) }, exp_val, tnew.desc('', vals, exp_val.toString())) } else { tnew[opt.assert](fn.apply(null, vals), exp_val, tnew.desc('', vals, exp_val)) } } }) } // same signature as table_assert, but pretty-print the table with results instead of running assertions function print_table (tnew, tbl, fn, opt, print_mode) { var out = opt.print_out || console.log if (opt.assert === 'same' || opt.assert === 'equal') { var last_header = tbl.header[tbl.header.length - 1] // replace last column with results of output from first cols tbl.rows.forEach(function (row) { var vals = row._vals().slice() vals.pop() if (opt.trunc) { vals = tnew.trunc(vals) } row[last_header] = fn.apply(null, vals) }) } // else just format all cols (we can add special assert handling as needed) var as_arrays = tbl.as_arrays({with_comments: true}) out('PRINT TABLE:') out(jstr.table(as_arrays, {lang: print_mode})) if (!opt.print_out) { // if print out is set, then assume caller is doing the assertions. tnew.ok(1, 'print table finished') tnew.end() } } function err (msg) { throw Error(msg) } function type (v) { var ret = Object.prototype.toString.call(v) return ret.substring(8, ret.length - 1).toLowerCase() } function plan (torig, tnew) { return function (n) { tnew.planned_tests = n // mark tests as planned (see tableAssert) return torig.plan(n) } } function countstr (src, v) { type(v) === 'string' || err('value should be a string: ' + type(v)) v.length > 0 || err('cannot count zero-length string') var c = 0, i = 0 if (v.length === 1) { var len = src.length for (i = 0; i < len; i++) { if (src[i] === v) c++ } } else { for (i = src.indexOf(v); i !== -1; i = src.indexOf(v, i + 1)) { c++ } } return c } function countbuf (src, v) { switch (type(v)) { case 'string': v.length === 1 || err('long strings not supported') v = v.charCodeAt(0) break case 'number': break default: throw Error('type not handled: ' + type(v)) } v === (v & 0xFF) || err('value for uint8array should be a byte (0-255)') var c = 0, len = src.length for (var i = 0; i < len; i++) { if (src[i] === v) c++ } return c } function count (src, v) { switch (type(src)) { case 'uint8array': return countbuf(src, v) case 'string': return countstr(src, v) case 'array': for (var i = 0, c = 0; i < src.length; i++) { if (src[i] === v) c++ } return c default: throw Error('type not handled: ' + type(src)) } } // inverse match (see readme) function imatch (s, re, opt) { opt = Object.assign({}, {empties: 'ignore', return: 'strings', no_match: 'string'}, opt) var prep_result = function (res) { if (opt.empties !== 'include') { res = res.filter(function (tpl) { return tpl[1] !== 0 }) } return opt.return === 'tuples' ? res : res.map(function (tpl) { return s.substr(tpl[0], tpl[1]) }) } var m = re.exec(s) if (!m) { switch (opt.no_match) { case 'null' : return null case 'string' : return prep_result([[0, s.length]]) case 'throw' : // fall-through default : throw Error(re.toString() + ' does not match string ' + s) } } var ret = [] var off = 0 do { var len = m.index - off ret.push([off, len]) off = m.index + m[0].length } while (re.lastIndex && (m = re.exec(s)) !== null) ret.push([off, s.length - off]) return prep_result(ret) } function ireplace (s, re, fn_or_string, opt) { var fn = typeof fn_or_string === 'function' ? fn_or_string : function () { return fn_or_string } opt = assign({}, opt) opt.return = 'tuples' // other imatch options 'empty' and 'no_match' are client-controlled. var m = imatch(s, re, opt) if (m === null) { return null // opt.empty was 'null' } var ret = [] var off = 0 m.forEach(function (tpl) { var toff = tpl[0], tlen = tpl[1] ret.push(s.substring(off, toff)) // matched portion (added intact) ret.push(fn(s.substr(toff, tlen), toff, s)) // unmatched portion (transform) off = toff + tlen }) ret.push(s.substring(off, s.length)) // remaining matched portion return ret.join('') } function hector (names) { var args = [] var max_num_args = 0 var ret = function () { args.push(Array.prototype.slice.call(arguments)) max_num_args = arguments.length > max_num_args ? arguments.length : max_num_args } ret.args = args // make args a simple/visible property ret.arg = function arg (which) { var i = which if (typeof i === 'string') { i = names ? names.indexOf(which) : -1 // no names will return array of undefined } return args.map(function (list) { return list[i] }) } return ret } // return a one-line string describing expected input and output of the form: // // lbl: [input_a, input_b..] -expect-> output // function desc (lbl, inp, out) { return lbl + ': ' + parens(inp) + ' -expect-> ' + parens([out]) } function tkprop (torig, tnew) { return function () { var k = arguments[0] switch (arguments.length) { case 0: return case 1: return tnew.tk_props[k] default: var v = arguments[1] if (v == null) { delete tnew.tk_props[k] } else { tnew.tk_props[k] = v } return } } } // Heap's Algorithm for generating all permutations of array 'a' function permut (a) { var p = []; _heaps(a.slice(), a.length, p); return p } function swap(a, i, j) { var t = a[i]; a[i] = a[j]; a[j] = t } function _heaps(a, n, p) { if (n === 1) { p.push(a.slice()) } else { for (var i = 0; i < n; i++) { _heaps(a, n - 1, p) swap(a, n % 2 ? 0 : i, n - 1) } } } // Creation functions are passed the original test object and the new test // object so they may invoke new or prior-defined functions (delegate). var DEFAULT_FUNCTIONS = { count: function () { return count }, desc: function () { return desc }, hector: function () { return hector }, permut: function () { return permut }, imatch: function () { return imatch }, ireplace: function () { return ireplace }, last: function () { return last }, lines: function () { return text_lines }, padl: function () { return padl }, padr: function () { return padr }, plan: function (torig, tnew) { return plan(torig, tnew) }, str: function () { return str }, sum: function () { return sum }, table: function () { return table }, tableAssert: function (torig, tnew) { return table_assert(torig, tnew) }, // backward-compatibility table_assert: function (torig, tnew) { return table_assert(torig, tnew) }, tkprop: function (torig, tnew) { return tkprop(torig, tnew) }, trunc: function () { return trunc }, type: function () { return type }, utf8: function () { return require('qb-utf8-ez').buffer }, utf8_to_str: function () { return require('qb-utf8-ez').string } } function testfn (name_or_fn, custom_fns, opt) { opt = opt || {} var test_module = null var test_orig = name_or_fn if (typeof name_or_fn === 'string') { try { test_module = require(name_or_fn) test_orig = test_module.test } catch(e) { var suggest = (typeof custom_fns === 'function') ? ' (It looks like the call to tape or tap was left out as in "require(\'test-kit\').tape()")' : '' err('could not load ' + name_or_fn + suggest + ': ' + e) } } // it isn't clear that passing in a test function is being used anywhere - consider deprecation and removal typeof test_orig === 'function' || err(name_or_fn + ' is not a function') var enrich_fns = assign({}, opt.custom_only ? {} : DEFAULT_FUNCTIONS, custom_fns) var ret var runner = new TestRunner(test_module, test_orig, enrich_fns) ret = function () { runner.addTest(arguments, {}) } ret.only = function () { runner.addTest(arguments, {only: true}) } ret.print = function () { runner.addTest(arguments, {only: true, print_mode: 'js'}) } ret.java = function () { runner.addTest(arguments, {only: true, print_mode: 'java' }) } ret.only1 = function () { runner.addTest(arguments, {only: true, trows: [0,1] })} runner.run() ret.engine = test_orig.only && test_orig.onFinish ? 'tape' : 'tap' // just a guess by what is likely Object.keys(test_orig).forEach(function (k) { if (!ret[k]) { var orig = test_orig[k] if (typeof orig === 'function') { ret[k] = function () { return orig.apply(test_orig, arguments) } // call function with original context } else { ret[k] = orig } } }) // ret.onFinish = test_orig.onFinish // only available in tape return ret } // property/function transforms applied to the test object passed into each test: // // test( 'my test', function(t) {...} ) // applied to the 't' object // // return a simple description of a function test: inputs -> outputs testfn.DEFAULT_FUNCTIONS = DEFAULT_FUNCTIONS module.exports = testfn // these convenience functions use dynamic 'require()' that allows test-kit to NOT depend on both tap and tape - // so required dependencies are kept light. module.exports.tap = function (custom_fns, opt) { return testfn('tap', custom_fns, opt) } module.exports.tape = function (custom_fns, opt) { return testfn('tape', custom_fns, opt) }