test.js/test.js
Alex A. Naanou 933b821c65 experimenting...
Signed-off-by: Alex A. Naanou <alex.nanou@gmail.com>
2020-10-16 02:30:35 +03:00

1007 lines
25 KiB
JavaScript

#!/usr/bin/env node
/**********************************************************************
*
* test.js
*
* Repo and docs:
* https://github.com/flynx/test.js
*
*
* TODO:
* - list found files...
* - flexible test chains with 0 or more modifiers...
* - might be a good idea to detect test module type and run only our
* ones...
*
*
***********************************************/ /* c8 ignore next 2 */
((typeof define)[0]=='u'?function(f){module.exports=f(require)}:define)
(function(require){ var module={} // make module AMD/node compatible...
/*********************************************************************/
var npath = require('path')
var colors = require('colors')
var glob = require('glob')
var object = require('ig-object')
var argv = require('ig-argv')
//---------------------------------------------------------------------
//
// DEFAULT_TEST_FILES =
// undefined
// | <path>
// | [ <path>, .. ]
//
module.DEFAULT_TEST_FILES = '**/?(*-)test.js'
//
// IGNORE_TEST_FILES =
// undefined
// | [ <path>, .. ]
//
module.IGNORE_TEST_FILES = ['node_modules/**']
//
// VERBOSE = <bool>
//
//
// NOTE: to test in verbose mode do:
// $ VERBOSE=1 npm test
// or
// $ VERBOSE=1 node test.js
// or set this manually after require('./test') but before calling
// the runner(..) or run(..)
// NOTE: this may change in the future...
module.VERBOSE = process ?
process.env.VERBOSE
: false
//---------------------------------------------------------------------
//---------------------------------------------------------------------
var getCallerFilename =
module.getCallerFilename =
function(){
var f = Error.prepareStackTrace
try {
var err = new Error()
// critical section...
// NOTE: would subclass Error and overload .prepareStackTrace(..)
// in it but it does not work, likely to that it is called
// directly on Error and not via the proto chain...
Error.prepareStackTrace = function(_, stack){ return stack }
var stack = err.stack
Error.prepareStackTrace = f
var cur = stack.shift().getFileName()
while(stack.length > 0){
var caller = stack.shift().getFileName()
if(caller && cur != caller){
return caller }}
} catch(e){}
// cleanup...
Error.prepareStackTrace !== f
&& (Error.prepareStackTrace = f)
return }
//---------------------------------------------------------------------
// compare two arrays by items...
// XXX this is defined in object.js/test.js and here, need to chose one...
var arrayCmp = function(a, b){
var ka = Object.keys(a)
var kb = Object.keys(a)
return a === b
|| (a.length == b.length
&& ka
// keep only non matching stuff...
.filter(function(k){
return a[k] !== b[k]
&& a[k] != a[k] })
.length == 0) }
// Assert constructor...
//
// Create an assert callable...
// Assert()
// Assert(path[, stats[, verbose]])
// -> assert
//
// Create an assert with extended path...
// assert.push(path)
// -> assert
//
// Create an assert with shortened path...
// assert.pop(path)
// -> assert
//
//
// Assertions...
//
// value is truthy...
// assert(value, msg, ..)
// -> value
//
// Assert truth and catch exceptions...
// assert.assert(msg, test())
// -> value
//
// Assert if test does not throw...
// assert.error(msg, test())
// -> error
//
//
// XXX this should be optional...
var Assert =
module.Assert =
object.Constructor('Assert', {
stats: null,
__verbose: null,
get verbose(){
return this.__verbose == null ?
module.VERBOSE
: this.__verbose },
set verbose(value){
value == null ?
(delete this.__verbose)
: (this.__verbose = value) },
// path API...
__str_path: null,
get strPath(){
return (this.__str_path =
this.__str_path
|| (this.path || []).join(':')) },
path: null,
push: function(path){
return this.constructor(
[
...(this.path || []),
...(path instanceof Array ?
path
: [path])
],
this.stats,
this.verbose) },
pop: function(){
return this.constructor(
(this.path || []).slice(0, -1),
this.stats,
this.verbose) },
// assertion API...
__call__: function(_, value, msg, ...args){
msg = msg || ''
// stats...
var stats = this.stats
stats.assertions = (stats.assertions || 0) + 1
!value
&& (stats.failures = (stats.failures || 0) + 1)
// assertions...
this.verbose
&& console.log(this.strPath +': '+ msg.bold, ...args)
console.assert(value, this.strPath.bold +': '+ msg.bold.yellow, ...args)
return value },
istrue: function(msg, test){
try {
return this(test.call(this), msg)
} catch(err){
this(false, msg)
return err } },
error: function(msg, test){
try {
test.call(this)
return this(false, msg)
} catch(err){
this(true, msg)
return err } },
// XXX
array: function(value, expected, msg){
msg = msg || ''
return this(arrayCmp(value, expected),
msg +':', 'expected:', expected, 'got:', value) },
__init__: function(path, stats, verbose){
this.path = path instanceof Array ?
path
: [path]
this.stats = stats || {}
this.verbose = verbose
},
})
//---------------------------------------------------------------------
// XXX
var mergeIter = function(iter){
// XXX should this get a function as argument???
return function(c){
c = c || this
return (c.members || [])
.map(function(e){ return Object[iter](e) })
.flat() } }
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
//
// Merged(name, func)
// -> merged
//
// Merged({name: func, ..})
// -> merged
//
//
// Merged is the sum of all its members.
//
// XXX is this generic enough to be moved to ig-types???
var Merged =
module.Merged =
object.Constructor('Merged', {
__members: undefined,
get members(){
return this.__members == null ?
(this.__members = [])
: this.__members },
set members(value){
this.__members = value },
// NOTE: this is the number of member elements and not the number
// of members...
// for the number of members use:
// <constructor>.members.length
// NOTE: we are using .size and not .length here because .length is
// used to indicate the number of arguments to a callable/function
// in JS...
get size(){
return this.keys().length },
// Like .size but does not count the pass-through elements...
get usize(){
var k = this.keys()
return k.length - (k.includes('-') ? 1 : 0) },
add: function(member){
this.members.push(member)
return this },
remove: function(member){
var m = this.members
var i = m.indexOf(member)
i >= 0
&& m.splice(i, 1)
return this },
clear: function(){
delete this.__members
return this },
keys: mergeIter('keys'),
values: mergeIter('values'),
entries: mergeIter('entries'),
toObject: function(){
return Object.fromEntries(this.entries()) },
//
// .checkShadowing()
// -> shadows
//
// .checkShadowing(other)
// -> shadows
//
checkShadowing: function(other){
var existing = new Set(this.keys())
other = other || this.members
return (other instanceof Array ?
other
: [other])
.map(function(o){
return Object.keys(o)
.filter(function(k){
return existing.has(k) }) })
.flat() },
handleShadowing: function(shadowed){
shadowed.length > 0
&& console.warn(` WARNING:`.bold, `shadowing: ${shadowed.join()}`)
return this },
create: function(name){
return object.Constructor(name || this.name, this, {}) },
}, {
filename: undefined,
__init__: function(other){
// parse args...
if(arguments.length == 2){
var [name, func] = arguments
other = {[name]: func} }
// set .filename on tests...
var f = getCallerFilename()
Object.entries(Object.getOwnPropertyDescriptors(other))
.forEach(function([k, p]){
typeof(p.value) == 'function'
&& (p.value.filename = p.value.filename || f) })
// check for shadowing...
this.constructor.handleShadowing(
this.constructor.checkShadowing(other))
// mix and merge...
object.mixinFlat(this, other)
this.constructor.add(this) },
})
//---------------------------------------------------------------------
var TestSet =
module.TestSet =
object.Constructor('TestSet', {
// XXX make these chainable...
// ...at the same time need to keep the API...
Setup: null,
setups: null,
Modifier: null,
modifiers: null,
Test: null,
tests: null,
Case: null,
cases: null,
// merge one or more test sets as cases...
//
// .merge({ <name>: <test-set>, .. })
// -> this
//
// XXX do we need this as a proxy to .Cases???
merge: function(){
this.cases(...arguments)
return this },
__assert__: Assert,
// XXX run the local tests...
// XXX need to be able to use external assert...
// - from context...
// - from arg...
// XXX nested assert(..) need to report nestedness correctly...
// XXX should/can this return a meaningfull result for it to be used
// as a setup/mod???
__call__: function(context, chain, stats){
var assert
// running nested...
if(typeof(chain) == 'function'){
assert = chain
chain = null
stats = stats
|| assert.stats }
// parse chain...
chain = (chain == '*' || chain == null) ?
[]
: chain
chain = chain instanceof Array ?
chain
: chain.split(/:/)
var chain_length = chain.length
var setup = chain.shift() || '*'
var test = chain.pop() || '*'
var mod = chain.pop() || '*'
mod = chain_length == 2 ?
'as-is'
: mod
// get the tests...
var {setups, modifiers, tests, cases} = this
;[setups, modifiers, tests, cases] =
[setups, modifiers, tests, cases]
.map(function(e){
return object.parentOf(Merged, e) ?
e.toObject()
: (e || {}) })
// stats...
stats = stats || {}
Object.assign(stats, {
tests: stats.tests || 0,
assertions: stats.assertions || 0,
failures: stats.failures || 0,
time: stats.time || 0,
})
var started = Date.now()
// tests...
// XXX revise nested assert...
var assert = assert
|| this.__assert__('[TEST]', stats, module.VERBOSE)
chain_length != 1
&& object.deepKeys(tests)
.filter(function(t, i, l){
return typeof(tests[t]) == 'function'
// skip blank tests if we have other tests unless
// explicitly specified...
&& ((t == '-'
&& test != t
&& l.length > 1) ?
false
: (test == '*'
|| test == t) ) })
.forEach(function(t){
// modifiers...
object.deepKeys(modifiers)
.filter(function(m){
return typeof(modifiers[m]) == 'function'
&& (mod == '*' || mod == m) })
.forEach(function(m){
// setups...
object.deepKeys(setups)
.filter(function(s){
return typeof(setups[s]) == 'function'
&& (setup == '*' || setup == s) })
.forEach(function(s){
// run the test...
stats.tests += 1
var _assert = assert.push(
[s, m, t]
// do not print blank pass-through ('-')
// components...
.filter(function(e){ return e != '-' }) )
tests[t](_assert,
modifiers[m](_assert,
setups[s](_assert))) }) }) })
// cases...
// XXX revise nested assert...
assert = assert
|| this.__assert__('[CASE]', stats, module.VERBOSE)
chain_length <= 1
&& Object.keys(cases)
.filter(function(s){
return typeof(cases[s]) == 'function'
&& (setup == '*' || setup == s) })
.forEach(function(c){
stats.tests += 1
cases[c]( assert.push(c) ) })
// runtime...
stats.time += Date.now() - started
return stats },
__init__: function(func){
// test collections...
this.Setup =
this.setups =
Merged.create('Setups')
this.Modifier =
this.modifiers =
Merged.create('Modifiers')
// default blank pass-through...
// NOTE: we need at least one modifier and at least
// one test for the system to run....
.add({ '-': function(_, s){ return s }})
this.Test =
this.tests =
Merged.create('Tests')
// default blank pass-through...
// NOTE: we need at least one modifier and at least
// one test for the system to run....
.add({ '-': function(_, s){ return s }})
this.Case =
this.cases =
Merged.create('Cases')
// init...
// XXX should this also get assert???
func
&& func.call(this) },
})
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// XXX rename to runner...
module.BASE_TEST_SET = new TestSet()
module.Setup =
module.Setups =
module.BASE_TEST_SET.Setup
module.Modifier =
module.Modifiers =
module.BASE_TEST_SET.Modifier
module.Test =
module.Tests =
module.BASE_TEST_SET.Test
module.Case =
module.Cases =
module.BASE_TEST_SET.Case
// XXX this is just a proxy to Cases(..), do we need it?
module.merge =
module.BASE_TEST_SET.merge
//---------------------------------------------------------------------
// Test runner/combinator...
//
// runner(spec)
// runner(spec, '*')
// -> stats
//
// runner(spec, 'case')
// runner(spec, 'setup:test')
// runner(spec, 'setup:mod:test')
// -> stats
//
//
// This will run
// test(modifier(setup))
// for each test in spec.tests
// for each modifier in spec.modifiers
// for each setup in spec.setups
// case()
// for each case in spec.cases
//
//
// NOTE: chaining more than one modifier is not yet supported (XXX)
//
// XXX chain N modifiers...
// XXX make Assert optional...
// XXX is this a good name???
//* XXX do we need a root runner???
// ...if not then need to cleanup run(..) to use TestSet / BASE_TEST_SET...
var runner =
module.runner =
function(spec, chain, stats){
// parse chain...
chain = (chain == '*' || chain == null) ?
[]
: chain
chain = chain instanceof Array ?
chain
: chain.split(/:/)
var chain_length = chain.length
var setup = chain.shift() || '*'
var test = chain.pop() || '*'
var mod = chain.pop() || '*'
mod = chain_length == 2 ?
'as-is'
: mod
// get the tests...
var {setups, modifiers, tests, cases} = spec
;[setups, modifiers, tests, cases] =
[setups, modifiers, tests, cases]
.map(function(e){
return object.parentOf(Merged, e) ?
e.toObject()
: (e || {}) })
// stats...
stats = stats || {}
Object.assign(stats, {
tests: stats.tests || 0,
assertions: stats.assertions || 0,
failures: stats.failures || 0,
time: stats.time || 0,
})
var started = Date.now()
// tests...
var assert = Assert('[TEST]', stats, module.VERBOSE)
chain_length != 1
&& object.deepKeys(tests)
.filter(function(t, i, l){
return typeof(tests[t]) == 'function'
// skip blank tests if we have other tests unless
// explicitly specified...
&& ((t == '-'
&& test != t
&& l.length > 1) ?
false
: (test == '*'
|| test == t) ) })
.forEach(function(t){
// modifiers...
object.deepKeys(modifiers)
.filter(function(m){
return typeof(modifiers[m]) == 'function'
&& (mod == '*' || mod == m) })
.forEach(function(m){
// setups...
object.deepKeys(setups)
.filter(function(s){
return typeof(setups[s]) == 'function'
&& (setup == '*' || setup == s) })
.forEach(function(s){
// run the test...
stats.tests += 1
var _assert = assert.push(
[s, m, t]
// do not print blank pass-through ('-')
// components...
.filter(function(e){ return e != '-' }) )
tests[t](_assert,
modifiers[m](_assert,
setups[s](_assert))) }) }) })
// cases...
var assert = Assert('[CASE]', stats, module.VERBOSE)
chain_length <= 1
&& Object.keys(cases)
.filter(function(s){
return typeof(cases[s]) == 'function'
&& (setup == '*' || setup == s) })
.forEach(function(c){
stats.tests += 1
cases[c]( assert.push(c) ) })
// runtime...
stats.time += Date.now() - started
return stats }
//*/
//---------------------------------------------------------------------
// CLI...
//
var parser =
module.parser =
argv.Parser({
// doc...
usage: `$SCRIPTNAME [OPTIONS] [CHAIN] ...`,
doc: object.text`Run tests.
Tests run by $SCRIPTNAME can be specified in one of the
following formats:
<case>
<setup>:<test>
<setup>:<modifier>:<test>
Each of the items in the test spec can be a "*" indicating
that all relevant items should be used, for example:
$ ./$SCRIPTNAME basic:*:*
Here $SCRIPTNAME is instructed to run all tests and modifiers
only on the basic setup.
Zero or more sets of tests can be specified.
When no tests specified $SCRIPTNAME will run all tests.
`,
examples: [
['$ ./$SCRIPTNAME',
'run all tests.'.gray],
['$ ./$SCRIPTNAME basic:*:*',
'run all tests and modifiers on "basic" setup.'.gray,
'(see $SCRIPTNAME -l for more info)'.gray],
['$ ./$SCRIPTNAME -v example',
'run "example" test in verbose mode.'.gray],
['$ ./$SCRIPTNAME native:gen3:methods init:gen3:methods',
'run two tests/patterns.'.gray],
//['$ export VERBOSE=1 && ./$SCRIPTNAME',
// 'set verbose mode globally and run tests.'.gray],
],
// list tests...
//
// NOTE: this uses .helpColumnOffset to align origins...
default_files: undefined,
// XXX if we do the printing in .stop(..) this will see all the modules...
'-l': '-list',
'-list': {
doc: ['list available tests;',
'note that if passing files via -f explicitly they',
'must precede the -l/-list flag;',
'this has the same defaults as -f'],
arg: 'PATH',
handler: function(args, key, path){
path = path || this.default_files
// load path or the defaults if nothing loaded...
path
&& (path != this.default_files
|| this.test_modules == null)
&& this.handle('-f', [], key, path)
// load the queued modules...
this.loadModule()
var offset = (this.helpColumnOffset || 3) * 8
// get key value...
var keys = function(s){
return (object.parentOf(Merged, s) ?
s.entries()
: Object.entries(s))
.map(function([k, v]){
var o = offset - k.length
var s = [
k,
v.filename ?
' '.repeat(o > 0 ? o : offset)
+ `- ${ npath.relative(process.cwd(), v.filename) }`
: '' ]
return o > 0 ?
s.join('')
: s })
.flat() }
// XXX should this account for pass-through elements...
var len = function(s){
return (object.parentOf(Merged, s) ?
s.size
: Object.keys(s).length) }
var a, b, c, d
console.log(
object.text`
Tests run by %s can be of the following forms:
<case>
<setup>:<test>
<setup>:<modifier>:<test>
Setups (${ (a = len(this.setups)) +'' }):
${ keys(this.setups).join('\n\
') }
Modifiers (${ ((b = len(this.modifiers)) - 1) +'' }):
${ keys(this.modifiers)
.filter(function(e){ return e != '-' })
.join('\n\
') }
Tests (${ (c = len(this.tests) - 1) +''}):
${ keys(this.tests)
.filter(function(e){ return e != '-' })
.join('\n\
') }
Standalone test cases (${ (d = len(this.cases)) +'' }):
${ keys(this.cases).join('\n\
') }
Total number of test chains: ${ ((a * b * (c || 1)) + d) +'' }
`, this.scriptName)
process.exit() }},
// list found modules...
//
// XXX use tab size...
'-list-found': {
doc: 'like -list but print found test modules and exit',
arg: 'PATH',
handler: function(args, key, path){
path = path || this.default_files
// load path or the defaults if nothing loaded...
path
&& (path != this.default_files
|| this.test_modules == null)
&& this.handle('-f', [], key, path)
var modules = Object.keys(this.test_modules || {})
console.log([
`Found modules (${ modules.length+'' }):`,
...modules
// XXX use tab size...
].join('\n '))
process.exit() }},
// queue files/patterns...
// XXX should this energetically load modules (current) or queue
// them for later loading (on .then(..))...
// ...should this be an option???
test_modules: undefined,
queueModule: function(path){
;(this.test_modules = this.test_modules || {})[path] = undefined
return this },
loadModule: function(path){
var that = this
path = path || Object.keys(this.test_modules || {})
path = path instanceof Array ?
path
: [path]
path
// do not reload modules...
.filter(function(path){
return !(that.test_modules || {})[path] })
.forEach(function(path){
console.log('Loading module:', path)
// XXX should we handle the load error here???
;(that.test_modules = that.test_modules || {})[path] =
require(process.cwd() +'/'+ path.slice(0, -3)) })
return this },
// XXX revise error handling...
'-f': '-test-file',
'-test-file': {
doc: ['test script or filename pattern, supports glob;',
'this flag can be given multiple times for',
'multiple paths/patterns'],
arg: 'PATH',
default: function(){
return this.default_files },
handler: function(args, key, path){
var that = this
;(path instanceof Array ?
path
: [path])
.forEach(function(path){
;(path.includes('*') ?
// search...
glob.sync(path, {
ignore: this.ignore_files
|| module.IGNORE_TEST_FILES
|| [], })
: [path])
// load the modules...
.forEach(function(path){
// only load .js files...
if(!/.*\.js$/.test(path)){
throw argv.ParserError(
`${key}: only support .js modules, got: "${path}"`) }
//that.loadModule(path) }) }) }},
that.queueModule(path) }) }) }},
// ignore paths...
ignore_files: undefined,
'-i': '-ignore',
'-ignore': {
doc: 'path/pattern to ignore in test file search',
arg: 'PATH | ignore_files',
collect: 'list',
default: function(){
return this.ignore_files
|| module.IGNORE_TEST_FILES
|| [] } },
'-verbose': {
doc: 'verbose mode',
env: 'VERBOSE',
handler: function(){
module.VERBOSE = true }},
// hide stuff we do not need...
'-quiet': undefined,
'-': undefined,
// XXX might be a good idea to check chain syntax here...
'@*': undefined,
})
// load the modules...
.then(function(){
this.loadModule() })
//---------------------------------------------------------------------
// Base runner...
//
// run()
// run(tests)
// run(default_files)
// run(default_files, tests)
// -> parse-result
//
//
// tests format:
// {
// setups: <setup-object>,
//
// modifiers: <modifier-object>,
//
// tests: <tests-object>,
//
// cases: <cases-object>,
//
// ...
// }
//
var run =
module.run =
function(default_files, tests){
// parse args -- run(tests)...
if(!(default_files instanceof Array
|| typeof(default_files) == typeof('str'))){
tests = default_files
default_files = undefined }
// patch require.cache...
// NOTE: this will make all the client scripts see the global module
// instead of local stuff...
if(typeof(__filename) != 'undefined'
&& __filename == (require.main || {}).filename){
// XXX is guessing this the correct way to do this???
// ...should we use glog.sync(process.cwd()+'/**/ig-test/test.js') instead???
var local = npath.join(process.cwd(), 'node_modules', 'ig-test', 'test.js')
require.cache[local] = require.cache[require.main.filename] }
var stats = {}
var tests = tests
|| {
setups: module.Setups,
modifiers: module.Modifiers,
tests: module.Tests,
cases: module.Cases,
}
var p = Object.assign(
Object.create(parser),
tests,
{
default_files: default_files,
})
return p
// XXX should this be generic???
.then(function(chains){
// run the tests...
chains.length > 0 ?
chains
.forEach(function(chain){
runner(tests, chain, stats) })
: runner(tests, '*', stats)
// print stats...
console.log(
'Tests run:', stats.tests,
' Assertions:', stats.assertions,
' Failures:', stats.failures,
` (${stats.time}ms)`.bold.black)
// report error status to the OS...
process.exit(stats.failures)
})
.call() }
//---------------------------------------------------------------------
typeof(__filename) != 'undefined'
&& __filename == (require.main || {}).filename
// NOTE: we are delaying code execution here to give the module a
// chance to complete loading and the clients to use its
// content. Otherwise the clients will get a partially formed
// module...
// This is needed only here, client code can safely and simply
// call run(..)
&& setTimeout(run.bind(null, module.DEFAULT_TEST_FILES), 0)
/**********************************************************************
* vim:set ts=4 sw=4 : */ return module })