Last active
January 25, 2017 03:19
-
-
Save dead-claudia/6b124d81366f629ef7443e44ca8e451b to your computer and use it in GitHub Desktop.
Thallium `lib/core/tests.js` with ES2017 equivalent
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"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