Skip to content

Instantly share code, notes, and snippets.

@dead-claudia
Last active January 25, 2017 03:19
Show Gist options
  • Save dead-claudia/6b124d81366f629ef7443e44ca8e451b to your computer and use it in GitHub Desktop.
Save dead-claudia/6b124d81366f629ef7443e44ca8e451b to your computer and use it in GitHub Desktop.
Thallium `lib/core/tests.js` with ES2017 equivalent
import {
Types,
Start, Enter, Leave, Pass, Fail, Skip, End, Hook,
Error as InternalError, HookError,
} from "./reports"
import {isOnly} from "./only"
/**
* The tests are laid out in a very data-driven design. With exception of the
* reports, there is minimal object orientation and zero virtual dispatch.
* Here's a quick overview:
*
* - The test handling dispatches based on various attributes the test has. For
* example, roots are known by a circular root reference, and skipped tests
* are known by not having a callback.
*
* - The test evaluation is very procedural. Although it's very highly
* asynchronous, the use of promises linearize the logic, so it reads very
* much like a recursive set of steps.
*
* - The data types are mostly either plain objects or classes with no methods,
* the latter mostly for debugging help. This also avoids most of the
* indirection required to accommodate breaking abstractions, which the API
* methods frequently need to do.
*/
// Prevent Sinon interference when they install their mocks
const {setTimeout, clearTimeout, Date: {now}} = global
/**
* Basic data types
*/
class Result {
constructor(time, caught, value) {
this.time = time
this.caught = caught
this.value = value
}
}
/**
* Overview of the test properties:
*
* - `methods` - A deprecated reference to the API methods
* - `root` - The root test
* - `reporters` - The list of reporters
* - `current` - A reference to the currently active test
* - `timeout` - The tests's timeout, or 0 if inherited
* - `slow` - The tests's slow threshold
* - `name` - The test's name
* - `index` - The test's index
* - `parent` - The test's parent
* - `callback` - The test's callback
* - `tests` - The test's child tests
* - `beforeAll`, `beforeEach`, `afterEach`, `afterAll` - The test's various
* scheduled hooks
*
* Many of these properties aren't present on initialization to save memory.
*/
// TODO: remove `test.methods` in 0.4
class Normal {
constructor(name, index, parent, callback) {
const child = Object.create(parent.methods)
child._ = this
this.methods = child
this.locked = true
this.root = parent.root
this.name = name
this.index = index|0
this.parent = parent
this.callback = callback
}
}
class Skipped {
constructor(name, index, parent) {
this.locked = true
this.root = parent.root
this.name = name
this.index = index|0
this.parent = parent
}
}
// TODO: remove `test.methods` in 0.4
class Root {
constructor(methods) {
this.locked = false
this.methods = methods
this.reporterIds = []
this.reporters = []
this.current = this
this.root = this
this.timeout = 0
this.slow = 0
}
}
/**
* Base tests (i.e. default export, result of `internal.root()`).
*/
export function createRoot(methods) {
return new Root(methods)
}
/**
* Set up each test type.
*/
/**
* A normal test through `t.test()`.
*/
export function addNormal(parent, name, callback) {
const index = parent.tests != null ? parent.tests.length : 0
const base = new Normal(name, index, parent, callback)
if (index) {
parent.tests.push(base)
} else {
parent.tests = [base]
}
}
/**
* A skipped test through `t.testSkip()`.
*/
export function addSkipped(parent, name) {
const index = parent.tests != null ? parent.tests.length : 0
const base = new Skipped(name, index, parent)
if (index) {
parent.tests.push(base)
} else {
parent.tests = [base]
}
}
/**
* Clear the tests in place.
*/
export function clearTests(parent) {
parent.tests = null
}
/**
* Execute the tests
*/
function path(test) {
const ret = []
let name, index
while (test.root !== test) {
;({name, index, parent: test}) = test
ret.push({name, index})
}
return ret.reverse()
}
// Note that a timeout of 0 means to inherit the parent.
export function timeout(test) {
while (!test.timeout && test.root !== test) {
test = test.parent
}
return test.timeout || 2000 // ms - default timeout
}
// Note that a slowness threshold of 0 means to inherit the parent.
export function slow(test) {
while (!test.slow && test.root !== test) {
test = test.parent
}
return test.slow || 75 // ms - default slow threshold
}
async function report(test, type, arg1, arg2) {
function invokeReporter(reporter) {
switch (type) {
case Types.Start:
return reporter(new Start())
case Types.Enter:
return reporter(new Enter(path(test), arg1, slow(test)))
case Types.Leave:
return reporter(new Leave(path(test)))
case Types.Pass:
return reporter(new Pass(path(test), arg1, slow(test)))
case Types.Fail:
return reporter(new Fail(path(test), arg1, arg2, slow(test)))
case Types.Skip:
return reporter(new Skip(path(test)))
case Types.End:
return reporter(new End())
case Types.Error:
return reporter(new InternalError(arg1))
case Types.Hook:
return reporter(new Hook(path(test), path(arg1), arg2))
default:
throw new TypeError("unreachable")
}
}
if (test.root.reporter != null) await invokeReporter(test.root.reporter)
const {reporters} = test.root
// Two easy cases.
if (reporters.length) {
if (reporters.length === 1) await invokeReporter(reporters[0])
else await Promise.all(reporters.map(invokeReporter))
}
}
/**
* Normal tests
*/
// PhantomJS and IE don't add the stack until it's thrown. In failing async
// tests, it's already thrown in a sense, so this should be normalized with
// other test types.
var addStack = typeof new Error().stack !== "string"
? e => {
try {
if (e instanceof Error && e.stack == null) throw e
} finally {
return e
}
}
: function (e) { return e }
await function invokeInit(test) {
test.locked = true
const start = now()
try {
const result = test.callback.call(test.methods, test.methods)
// Set the timeout *after* initialization. The timeout will likely be
// specified during initialization.
const maxTimeout = timeout(test)
// Setting a timeout is pointless if it's infinite.
if (maxTimeout !== Infinity) {
let timer
await Promise.race([
result,
new Promise((_, reject) => {
timer = setTimeout.call(global,
() => reject(
new Error(`Timeout of ${maxTimeout} reached`)),
maxTimeout)
}),
])
clearTimeout(timer)
} else {
await result
}
return new Result(now() - start, false)
} catch (e) {
return new Result(now() - start, true, addStack(e))
} finally {
test.locked = true
}
}
class ErrorWrap extends Error {
constructor(test, error) {
super()
this.test = test
this.error = error
}
get name() {
return "ErrorWrap"
}
}
async function invokeHook(test, list, stage) {
if (list == null) return
for (const hook of list) {
try {
await hook()
} catch (e) {
throw new ErrorWrap(test, new HookError(stage, hook, e))
}
}
}
async function invokeBeforeEach(test) {
const stack = [test]
while (test.root !== test) {
stack.push(test = test.parent)
}
while (stack.length) {
const test = queue.pop()
await invokeHook(test, test.beforeEach, Types.BeforeEach)
}
}
async function invokeAfterEach(test) {
await invokeHook(test, test.afterEach, Types.AfterEach)
while (test.root !== test) {
test = test.parent
await invokeHook(test, test.afterEach, Types.AfterEach)
}
}
async function runChildTests(test) {
if (test.tests == null) return
let ran = false
for (const child of test.tests) {
test.root.current = child
try {
// Only skipped tests have no callback
if (child.callback == null) {
await report(child, Types.Skip)
} else if (isOnly(child)) {
if (!ran) {
ran = true
await invokeHook(test, test.beforeAll, Types.BeforeAll)
}
try {
await invokeBeforeEach(test)
await runNormalChild(child)
await invokeAfterEach(test)
} catch (e) {
if (!(e instanceof ErrorWrap)) throw e
await report(child, Types.Hook, e.test, e.error)
}
}
} finally {
test.root.current = test
}
}
if (ran) await invokeHook(test, test.afterAll, Types.AfterAll)
}
function clearChildren(test) {
if (test.tests == null) return
for (const child of test.tests) {
delete child.tests
}
}
async function runNormalChild(test) {
try {
const {caught, value, time} = await invokeInit(test)
if (caught) {
await report(test, Types.Fail, value, time)
} else if (test.tests == null) {
await report(test, Types.Pass, time)
} else {
// Report this as if it was a parent test if it's passing and has
// children.
await report(test, Types.Enter, time)
try {
await runChildTests(test)
await report(test, Types.Leave)
} catch (e) {
if (!(e instanceof ErrorWrap)) throw e
await report(test, Types.Leave)
await report(test, Types.Hook, e.test, e.error)
}
}
} finally {
clearChildren(test)
}
}
/**
* This runs the root test and returns a promise resolved when it's done.
*/
export async function runTest(test) {
test.locked = true
await report(test, Types.Start)
try {
try {
await runChildTests(test)
} catch (e) {
if (!(e instanceof ErrorWrap)) throw e
return report(test, Types.Hook, e.test, e.error)
}
await report(test, Types.End)
} catch (e) {
await report(test, Types.Error, e)
throw e
} finally {
clearChildren(test)
test.locked = false
}
}
"use strict"
var methods = require("../methods")
var peach = require("../util").peach
var Reports = require("./reports")
var isOnly = require("./only").isOnly
var Types = Reports.Types
/**
* The tests are laid out in a very data-driven design. With exception of the
* reports, there is minimal object orientation and zero virtual dispatch.
* Here's a quick overview:
*
* - The test handling dispatches based on various attributes the test has. For
* example, roots are known by a circular root reference, and skipped tests
* are known by not having a callback.
*
* - The test evaluation is very procedural. Although it's very highly
* asynchronous, the use of promises linearize the logic, so it reads very
* much like a recursive set of steps.
*
* - The data types are mostly either plain objects or classes with no methods,
* the latter mostly for debugging help. This also avoids most of the
* indirection required to accommodate breaking abstractions, which the API
* methods frequently need to do.
*/
// Prevent Sinon interference when they install their mocks
var setTimeout = global.setTimeout
var clearTimeout = global.clearTimeout
var now = global.Date.now
/**
* Basic data types
*/
function Result(time, attempt) {
this.time = time
this.caught = attempt.caught
this.value = attempt.caught ? attempt.value : undefined
}
/**
* Overview of the test properties:
*
* - `methods` - A deprecated reference to the API methods
* - `root` - The root test
* - `reporters` - The list of reporters
* - `current` - A reference to the currently active test
* - `timeout` - The tests's timeout, or 0 if inherited
* - `slow` - The tests's slow threshold
* - `name` - The test's name
* - `index` - The test's index
* - `parent` - The test's parent
* - `callback` - The test's callback
* - `tests` - The test's child tests
* - `beforeAll`, `beforeEach`, `afterEach`, `afterAll` - The test's various
* scheduled hooks
*
* Many of these properties aren't present on initialization to save memory.
*/
// TODO: remove `test.methods` in 0.4
function Normal(name, index, parent, callback) {
var child = Object.create(parent.methods)
child._ = this
this.methods = child
this.locked = true
this.root = parent.root
this.name = name
this.index = index|0
this.parent = parent
this.callback = callback
}
function Skipped(name, index, parent) {
this.locked = true
this.root = parent.root
this.name = name
this.index = index|0
this.parent = parent
}
// TODO: remove `test.methods` in 0.4
function Root(methods) {
this.locked = false
this.methods = methods
this.reporterIds = []
this.reporters = []
this.current = this
this.root = this
this.timeout = 0
this.slow = 0
}
/**
* Base tests (i.e. default export, result of `internal.root()`).
*/
exports.createRoot = function (methods) {
return new Root(methods)
}
/**
* Set up each test type.
*/
/**
* A normal test through `t.test()`.
*/
exports.addNormal = function (parent, name, callback) {
var index = parent.tests != null ? parent.tests.length : 0
var base = new Normal(name, index, parent, callback)
if (index) {
parent.tests.push(base)
} else {
parent.tests = [base]
}
}
/**
* A skipped test through `t.testSkip()`.
*/
exports.addSkipped = function (parent, name) {
var index = parent.tests != null ? parent.tests.length : 0
var base = new Skipped(name, index, parent)
if (index) {
parent.tests.push(base)
} else {
parent.tests = [base]
}
}
/**
* Clear the tests in place.
*/
exports.clearTests = function (parent) {
parent.tests = null
}
/**
* Execute the tests
*/
function path(test) {
var ret = []
while (test.root !== test) {
ret.push({name: test.name, index: test.index})
test = test.parent
}
return ret.reverse()
}
// Note that a timeout of 0 means to inherit the parent.
exports.timeout = timeout
function timeout(test) {
while (!test.timeout && test.root !== test) {
test = test.parent
}
return test.timeout || 2000 // ms - default timeout
}
// Note that a slowness threshold of 0 means to inherit the parent.
exports.slow = slow
function slow(test) {
while (!test.slow && test.root !== test) {
test = test.parent
}
return test.slow || 75 // ms - default slow threshold
}
function report(test, type, arg1, arg2) {
function invokeReporter(reporter) {
switch (type) {
case Types.Start:
return reporter(new Reports.Start())
case Types.Enter:
return reporter(new Reports.Enter(path(test), arg1, slow(test)))
case Types.Leave:
return reporter(new Reports.Leave(path(test)))
case Types.Pass:
return reporter(new Reports.Pass(path(test), arg1, slow(test)))
case Types.Fail:
return reporter(
new Reports.Fail(path(test), arg1, arg2, slow(test)))
case Types.Skip:
return reporter(new Reports.Skip(path(test)))
case Types.End:
return reporter(new Reports.End())
case Types.Error:
return reporter(new Reports.Error(arg1))
case Types.Hook:
return reporter(new Reports.Hook(path(test), path(arg1), arg2))
default:
throw new TypeError("unreachable")
}
}
return Promise.resolve()
.then(function () {
if (test.root.reporter == null) return undefined
return invokeReporter(test.root.reporter)
})
.then(function () {
var reporters = test.root.reporters
// Two easy cases.
if (reporters.length === 0) return undefined
if (reporters.length === 1) return invokeReporter(reporters[0])
return Promise.all(reporters.map(invokeReporter))
})
}
/**
* Normal tests
*/
// PhantomJS and IE don't add the stack until it's thrown. In failing async
// tests, it's already thrown in a sense, so this should be normalized with
// other test types.
var addStack = typeof new Error().stack !== "string"
? function addStack(e) {
try {
if (e instanceof Error && e.stack == null) throw e
} finally {
return e
}
}
: function (e) { return e }
function getThen(res) {
if (typeof res === "object" || typeof res === "function") {
return res.then
} else {
return undefined
}
}
function AsyncState(start, resolve) {
this.start = start
this.resolve = resolve
this.resolved = false
this.timer = undefined
}
function asyncFinish(state, attempt) {
// Capture immediately. Worst case scenario, it gets thrown away.
var end = now()
if (state.resolved) return
if (state.timer) {
clearTimeout.call(global, state.timer)
state.timer = undefined
}
state.resolved = true
state.resolve(new Result(end - state.start, attempt))
}
// Avoid a closure if possible, in case it doesn't return a thenable.
function invokeInit(test) {
var start = now()
var tryBody = try1(test.callback, test.methods, test.methods)
// Note: synchronous failures are test failures, not fatal errors.
if (tryBody.caught) {
return Promise.resolve(new Result(now() - start, tryBody))
}
var tryThen = try1(getThen, undefined, tryBody.value)
if (tryThen.caught || typeof tryThen.value !== "function") {
return Promise.resolve(new Result(now() - start, tryThen))
}
return new Promise(function (resolve) {
var state = new AsyncState(start, resolve)
var result = try2(tryThen.value, tryBody.value,
function () {
if (state == null) return
asyncFinish(state, tryPass())
state = undefined
},
function (e) {
if (state == null) return
asyncFinish(state, tryFail(addStack(e)))
state = undefined
})
if (result.caught) {
asyncFinish(state, result)
state = undefined
return
}
// Set the timeout *after* initialization. The timeout will likely be
// specified during initialization.
var maxTimeout = timeout(test)
// Setting a timeout is pointless if it's infinite.
if (maxTimeout !== Infinity) {
state.timer = setTimeout.call(global, function () {
if (state == null) return
asyncFinish(state, tryFail(addStack(
new Error("Timeout of " + maxTimeout + " reached"))))
state = undefined
}, maxTimeout)
}
})
}
function ErrorWrap(test, error) {
this.test = test
this.error = error
}
methods(ErrorWrap, Error, {name: "ErrorWrap"})
function invokeHook(test, list, stage) {
if (list == null) return Promise.resolve()
return peach(list, function (hook) {
try {
return hook()
} catch (e) {
throw new ErrorWrap(test, new Reports.HookError(stage, hook, e))
}
})
}
function invokeBeforeEach(test) {
if (test.root === test) {
return invokeHook(test, test.beforeEach, Types.BeforeEach)
} else {
return invokeBeforeEach(test.parent).then(function () {
return invokeHook(test, test.beforeEach, Types.BeforeEach)
})
}
}
function invokeAfterEach(test) {
if (test.root === test) {
return invokeHook(test, test.afterEach, Types.AfterEach)
} else {
return invokeHook(test, test.afterEach, Types.AfterEach)
.then(function () { return invokeAfterEach(test.parent) })
}
}
function runChildTests(test) {
if (test.tests == null) return undefined
function runChild(child) {
return invokeBeforeEach(test)
.then(function () { return runNormalChild(child) })
.then(function () { return invokeAfterEach(test) })
.then(
function () { test.root.current = test },
function (e) {
test.root.current = test
if (!(e instanceof ErrorWrap)) throw e
return report(child, Types.Hook, e.test, e.error)
})
}
var ran = false
function maybeRunChild(child) {
// Only skipped tests have no callback
if (child.callback == null) {
return report(child, Types.Skip)
} else if (!isOnly(child)) {
return Promise.resolve()
} else if (ran) {
return runChild(child)
} else {
ran = true
return invokeHook(test, test.beforeAll, Types.BeforeAll)
.then(function () { return runChild(child) })
}
}
return peach(test.tests, function (child) {
test.root.current = child
return maybeRunChild(child).then(
function () { test.root.current = test },
function (e) { test.root.current = test; throw e })
})
.then(function () {
return ran ? invokeHook(test, test.afterAll, Types.AfterAll) : undefined
})
}
function clearChildren(test) {
if (test.tests == null) return
for (var i = 0; i < test.tests.length; i++) {
delete test.tests[i].tests
}
}
function runNormalChild(test) {
test.locked = false
return invokeInit(test)
.then(
function (result) { test.locked = true; return result },
function (error) { test.locked = true; throw error })
.then(function (result) {
if (result.caught) {
return report(test, Types.Fail, result.value, result.time)
} else if (test.tests != null) {
// Report this as if it was a parent test if it's passing and has
// children.
return report(test, Types.Enter, result.time)
.then(function () { return runChildTests(test) })
.then(function () { return report(test, Types.Leave) })
.catch(function (e) {
if (!(e instanceof ErrorWrap)) throw e
return report(test, Types.Leave).then(function () {
return report(test, Types.Hook, e.test, e.error)
})
})
} else {
return report(test, Types.Pass, result.time)
}
})
.then(
function () { clearChildren(test) },
function (e) { clearChildren(test); throw e })
}
/**
* This runs the root test and returns a promise resolved when it's done.
*/
exports.runTest = function (test) {
test.locked = true
return report(test, Types.Start)
.then(function () { return runChildTests(test) })
.catch(function (e) {
if (!(e instanceof ErrorWrap)) throw e
return report(test, Types.Hook, e.test, e.error)
})
.then(function () { return report(test, Types.End) })
// Tell the reporter something happened. Otherwise, it'll have to wrap this
// method in a plugin, which shouldn't be necessary.
.catch(function (e) {
return report(test, Types.Error, e).then(function () { throw e })
})
.then(
function () {
clearChildren(test)
test.locked = false
},
function (e) {
clearChildren(test)
test.locked = false
throw e
})
}
// Help optimize for inefficient exception handling in V8
function tryPass(value) {
return {caught: false, value: value}
}
function tryFail(e) {
return {caught: true, value: e}
}
function try1(f, inst, arg0) {
try {
return tryPass(f.call(inst, arg0))
} catch (e) {
return tryFail(e)
}
}
function try2(f, inst, arg0, arg1) {
try {
return tryPass(f.call(inst, arg0, arg1))
} catch (e) {
return tryFail(e)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment