undo3d-run-test
Version:
A collection of utilities for testing Undo3D, and apps built with Undo3D
243 lines (193 loc) • 8.79 kB
JavaScript
//// LevelCase
////
//// Test cases are the third level of a test runner. They contain the actual
//// body of the test code, whch may be synchronous or asynchronous.
import assert from '../deps/node_modules/undo3d-shim-browser/assert/all.mjs'
import Level from './level.mjs'
import LevelSuite from './level-suite.mjs'
//// CLASS
export default class LevelCase extends Level {
constructor (options={}) {
const { suite, body } = options
super(options)
//// Record a back-ref to the test suite which contains this test case.
if ( invalidSuite(suite) ) throw Error( invalidSuite(suite) )
this._suite = suite
//// Record the actual test code. There must be one (but not both) of:
//// now(), for synchronous test code, or
//// wait(), for asynchronous test code
if ( invalidBody(body) ) throw Error( invalidBody(body) )
this._isAsync = bodyIsAsync(body)
this._body = body
////@TODO maybe allow before() and after(), somehow?
}
//// PUBLIC PROPERTIES
//// Xx (read-only).
get suite () { return this._suite }
get isAsync () { return this._isAsync }
get body () { return this._body }
get dash () { return this._isAsync ? '^' : '-' }
//// The test result, as a TAP string (read-only).
get tap () { return this.tapTestLine }
//// PUBLIC METHODS
//// Run the test case.
async run (parcel) {
const { hub, body, isAsync } = this
let result
//// Update the test case status and fire a 'start' event.
this._status = 'running'
hub.fire('case-start', this.suite.index, this.index)
//// Adds assertion methods to the `resolve()` function.
function attachAssertions (resolve) {
for (const key in assert)
resolve[key] = function (...args) {
try { assert[key].apply(null, args)
} catch (err) { return resolve(err) }
resolve('No error!') //@TODO pass 3rd arg instead of 'No error!'?
}
}
//// Adds custom properties to the `resolve()` function. Overwriting
//// methods added by attachAssertions() is not allowed.
function addParcelPropsToResolve (resolve) {
for (const key in parcel)
if (assert[key])
throw Error(`Can’t overwrite assert.${key}`)
else
resolve[key] = parcel[key]
}
//// Copies the custom properties added to the `resolve()` function back
//// onto `parcel`.
function getParcelPropsFromResolve (resolve) {
for (const key in resolve)
if (! assert[key]) { // ignore assertions
parcel[key] = resolve[key]
console.log(' replace ' + key + ' with ' + resolve[key]);
}
}
if (isAsync) {
let capturedResolve
function wrappedBody () {
return new Promise( function (resolve, reject) {
capturedResolve = resolve
attachAssertions(resolve)
addParcelPropsToResolve(resolve)
body(resolve)
})
}
result = await wrappedBody()
getParcelPropsFromResolve(capturedResolve)
} else {
try {
result = body(parcel)
} catch (err) {
result = err
}
}
//// Update the test case result and status, and fire an 'end' event.
this._result = result
this._status = result instanceof Error ? 'not ok' : 'ok'
hub.fire('case-end', this.suite.index, this.index)
//// The returned `this` is used by LevelSuite::run()::runTestCases().
return this
}
}
//// VALIDATE
function invalidSuite (suite) {
if (null == suite)
return `options.suite must be set`
if ('object' !== typeof suite)
return `options.suite is type '${typeof suite}' not 'object'`
if (! (suite instanceof LevelSuite))
return `options.suite is '${suite.constructor.name}' not 'LevelSuite'`
}
function invalidBody (body) {
if (null == body)
return `options.body must be set`
if ('function' !== typeof body)
return `options.body is type '${typeof body}' not 'function'`
}
//// UTILITY
//// Capture the argument-names and the main body from an old-school function.
//// Also captures the optional function name, though we don’t really need it.
const normalFunctionRx = new RegExp(
`^\\s*` // allow whitespace at start
+ `function\\s+` // literal 'function', and 1+ spaces
+ `([_a-z][_a-z0-9]*)?` // [1] capture the optional function name @TODO full range of allowed characters
+ `\\s*\\(\\s*` // begin arguments-bracket
+ `([\\s,_a-z0-9]*)` // [2] capture zero or more arguments @TODO full range of allowed characters
+ `\\s*\\)\\s*{\\s*` // end arguments-bracket, begin curly-brace
+ `([\\s\\S]*)` // [3] capture the main body
+ `\\s*}` // end curly-brace
+ `\\s*$` // allow whitespace at end
, 'i' // case insensitive
)
//// Capture the argument-names and the main body from an arrow function.
const arrowFunctionRx = new RegExp(
`^\\s*` // allow whitespace at start
+ `\\(?\\s*` // begin optional arguments-bracket
+ `([\\s,_a-z0-9]*)` // [1] capture 0+ arguments @TODO full range of allowed characters
+ `\\s*\\)?\\s*` // end optional arguments-bracket
+ `=>\\s*` // literal '=>'
+ `([\\s\\S]*)` // [2] capture main body INCLUDING curly-braces
+ `\\s*$` // allow whitespace at end
, 'i' // case insensitive
)
//// Capture the main body INSIDE curly-braces. This RegExp requires '{' at the
//// start AND '}' at the end, so it won’t be fooled by `x => x = {}`.
const curlyBraceWrapRx = new RegExp(
`^{\\s*` // begin curly-brace, and allow whitespace
+ `([\\s\\S]*)` // [1] capture main body INSIDE curly-braces
+ `\\s*}$` // end curly-brace, and allow whitespace
)
//// Xx.
function bodyIsAsync (body) {
//
// console.log('\n'+body);
//// Try parsing a normal function, eg `function fnName (arg) { arg('ok') }`
let matches = (body+'').match(normalFunctionRx)
//// Try parsing an arrow function, eg `arg => arg('ok')`. Then normalise
//// it to look like normalFunctionRx’s results.
if (! matches) {
matches = (body+'').match(arrowFunctionRx)
if (! matches) throw Error('options.body cannot be parsed')
matches.splice(1, 0, body.name) // matches[1] is the function name
const curlyBraceWrap = matches[3].match(curlyBraceWrapRx)
if (curlyBraceWrap) matches[3] = curlyBraceWrap[1]
}
//// Divide the arguments into an array.
let [ _, fnName, fnArgs, fnBody ] = matches
fnArgs = fnArgs.split(',').map( argName => argName.trim() )
//
// console.log(
// ' ' + (fnName || '(anonymous)')
// , '\n ' + fnArgs
// , '\n ' + fnBody
// );
//// If the function has no arguments, it can’t be trying to call parcel()
//// (passed to it by LevelCase::run()), so it must be synchronous.
if (0 === fnArgs.length) return false
//// Test case functions must specify either 0 or 1 argument.
if (1 < fnArgs.length) throw Error(
`options.body ${(fnName||'[anon]')}() has ${fnArgs.length} arguments`)
//// Determine whether the single argument is called directly:
//// `t => { t(new Error('Oh no!')) }`
//// or if one of its assertion-wrappers was called:
//// `t => { t.strictEqual('1', 1, 'Oh no!') }`
////@TODO edge case `t => { "in a string t()" }` should return false
////@TODO edge case `t => { /*in a comment t()*/ }` should return false
////@TODO edge case `t => { let x = t; x() }` should return true
const callUsage = (' '+fnBody).match( new RegExp( // ' '+ simplifies the rx
'[\\s;,{([]' //@TODO comments
+ fnArgs[0]
+ '\\s*'
+ '(\\.' // will be '(...|\\.strictEqual|\\.equal|...)?'
+ (Object.keys(assert).join('|\\.')) //@TODO ignore AssertionError and strict
+ ')?'
+ '\\s*\\('
) )
//
// console.log( callUsage ? ' Calls' : ' Does not call', fnArgs[0]+'()')
//// If the match succeeded, the function body calls its argument, which
//// means it’s asynchronous.
return !!callUsage
}