UNPKG

undo3d-run-test

Version:

A collection of utilities for testing Undo3D, and apps built with Undo3D

243 lines (193 loc) 8.79 kB
//// 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 }