deadunit
Version:
A dead-simple nestable unit testing library for javascript and node.js.
677 lines (560 loc) • 22 kB
JavaScript
"use strict";
/* Copyright (c) 2014 Billy Tetrud - Free to use for any purpose: MIT License*/
var Future = require('async-future')
var proto = require('proto')
var defaultFormats = require('./defaultFormats')
var Container = require('blocks.js/Container')
var OriginalText = require('blocks.js/Text')
var Block = require("blocks.js/Block")
Block.dev = true
var Style = require("blocks.js/Style")
var deadunitInternal = require("./deadunit.internal")
var utils = require("./utils")
module.exports = deadunitInternal({
deadunitCore: require('deadunit-core/src/deadunitCore.browser'),
environmentSpecificMethods: function() {
var red = 'rgb(200,30,30)'
var warningWritten = false
function warnAboutLateEvents(domNode) {
if(!warningWritten) {
append(domNode, "Test results were accessed before asynchronous parts of tests were fully complete.", {style: "color: red;"})
warningWritten = true
}
}
function writeLateEvent(written, ended, domNode, event, manager) {
if(ended) {
written.then(function() {
warnAboutLateEvents(domNode)
append(domNode, JSON.stringify(event), {style: "color: red;"})
})
}
}
// writes html on the current (browser) page
this.writeHtml = function(domNode) {
if(domNode === undefined) domNode = document.body
var f = new Future, groups = {}, ended = false, mainGroup, lateEventsWarningPrinted=false;
this.events({
group: function(groupStartEvent) {
if(groupStartEvent.parent === undefined) {
var group = mainGroup = MainGroup(groupStartEvent.name, groupStartEvent.time)
domNode.appendChild(mainGroup.domNode)
} else {
var group = Group(mainGroup, groupStartEvent.name, groupStartEvent.time, groups[groupStartEvent.parent])
group.parentGroup.addSubGroup(group)
}
groups[groupStartEvent.id] = group
lateEventCheck()
},
count: function(e) {
groups[e.parent].addExpectedCount(e.expected, Count(e.sourceLines, e.file, e.line, e.column, ended, e.expected))
lateEventCheck()
},
assert: function(e) {
groups[e.parent].addAssert(Assert(e.sourceLines, e.file, e.line, e.column, ended, e.expected, e.actual, e.success))
lateEventCheck()
},
exception: function(exceptionEvent) {
groups[exceptionEvent.parent].addException(Exception(exceptionEvent.error, ended))
lateEventCheck()
},
log: function(logEvent) {
groups[logEvent.parent].results.add(Log(logEvent.values, ended))
lateEventCheck()
},
groupEnd: function(groupEvent) {
var group = groups[groupEvent.id]
group.end(groupEvent.time, ended)
if(group.parentGroup !== undefined && group.state.subject.success) {
group.parentGroup.title.passed++
group.parentGroup.updateTitle()
}
lateEventCheck()
},
end: function(endEvent) {
mainGroup.endTest(endEvent.type, endEvent.time)
ended = true
f.return()
}
})
return f
// if late is true, prints out the late event warning, unless it's already been printed
function lateEventCheck() {
if(ended && !lateEventsWarningPrinted) {
mainGroup.add(Text('lateEventsWarning', "Warning: some events happened after the test ended."))
lateEventsWarningPrinted = true
}
}
}
}
})
function append(domNode, content, attributes) {
if(domNode.setAttributeNode === undefined || domNode.appendChild === undefined)
console.log("Object that is not a dom node passed to 'append' (jquery objects aren't supported anymore): "+domNode)
if(attributes === undefined) attributes = {}
/*var div = document.createElement('div')
div.innerHTML = content
for(var attribute in attributes) {
var a = document.createAttribute(attribute)
a.nodeValue = attributes[attribute]
domNode.setAttributeNode(a);
}
domNode.appendChild(div)
*/
$(domNode).append(content)
}
var color = defaultFormats.htmlColors
document.body.style.backgroundColor = color.black
var mainGroupStyle = Style({
color: color.white,
marginTop: 10,
Text: {
$mainTitle:{
cursor: 'pointer',
color: color.brightBlue,
fontSize: 28,
fontWeight: 'bold',
margin: '9px 0'
},
$timeout: {
color: color.red
},
$lateEventsWarning: {
color: color.yellow
}
},
Container:{$results:{
$state: function(state) {
if(state.success) {
if(state.late) {
var borderColor = color.darkYellow
} else {
var borderColor = color.green
}
} else {
var borderColor = color.red
}
return Style({
border: '1px solid '+borderColor,
display: 'block',
padding: 5
})
}
}},
MainBar: {
$state: function(state) {
if(state.success) {
if(state.late) {
var borderColor = color.darkYellow
} else {
var borderColor = color.green
}
} else {
var borderColor = color.red
}
return Style({
cursor: 'pointer',
border: "2px solid "+borderColor,
display: 'block',
padding: 1,
})
},
Container: {
$inner: {
$state: function(state) {
if(state.success) {
if(state.late) {
var backgroundColor = color.darkYellow
} else {
var backgroundColor = color.green
}
} else {
var backgroundColor = color.red
}
return Style({
backgroundColor: backgroundColor,
display: "block",
padding: "1px 3px",
})
},
Text: {
color: color.white,
$title: {
color: color.brightBlue
}
}
},
$passes: {
Text: {color: color.brightGreen}
},
$failures: {
Text: {color: color.darkRed}
},
$exceptions: {
Text: {color: color.brightPurple}
},
$clickText: {
float: 'right',
Text: {fontStyle: 'italic'}
}
},
},
Group: {
padding: 1,
margin: '8px 0',
GroupTitle: {
$state: function(state) {
if(state.success) {
var textColor = color.brightGreen
if(state.late) {
var backgroundColor = color.darkYellow
} else {
var backgroundColor = color.green
}
} else {
var textColor = color.white
var backgroundColor = color.red
}
return Style({
backgroundColor: backgroundColor,
color: textColor,
paddingLeft: 3,
cursor: 'pointer'
})
},
Text: {
$timeElapsed: {
color: color.gray
}
}
},
},
ResultLine: {
$state: function(state) {
if(!state.success) {
var textColor = color.red
} else if(state.late) {
var textColor = color.yellow
} else {
var textColor = color.green
}
return Style({color: textColor})
},
Container: {
$location: {
Text: {
color: color.gray,
$line: {
color: color.white
}
}
},
$expectedAndActual: {
Text: {
color: color.gray,
$actual: {
color: color.white
},
$expected: {
color: color.white
}
}
}
}
},
Exception: {
color: color.purple
},
Log: {
Text: {
display: 'block'
}
}
})
// a Block on its own line
var Line = proto(Block, function() {
this.name = "Line"
this.defaultStyle = Style({
display: 'block'
})
})
var Group = proto(Line, function() {
this.name = "Group"
this.build = function(mainGroup, groupTitle, time, parentGroup) {
this.mainGroup = mainGroup
this.results = Container('results')
this.parentGroup = parentGroup
this.add(this.results)
this.createTitleBar(groupTitle)
this.startTime = time
this.count = 0
this.title.on('click', function() {
this.results.visible = !this.results.visible
}.bind(this))
}
this.createTitleBar = function(groupTitle) {
this.title = GroupTitle(groupTitle)
this.addAt(0, this.title)
}
this.addExpectedCount = function(expected, countBlock) {
this.expected = expected
this.countBlock = countBlock
this.countBlock.count = this.count
this.results.addAt(0, countBlock)
this.title.total++
updateCountSuccess(this) // must be run before updateTitle (because it modifies info updateTitle relies on)
this.updateTitle()
}
this.addAssert = function(assertBlock) {
this.results.add(assertBlock)
this.count++
if(this.countBlock !== undefined)
this.countBlock.count = this.count
this.title.total++
if(assertBlock.state.subject.success) {
this.title.passed++
this.mainGroup.title.testTotalPasses++
} else {
this.mainGroup.title.testTotalFailures++
}
updateCountSuccess(this) // must be run before updateTitle (because it modifies info updateTitle relies on)
this.updateTitle()
}
this.addException = function(exceptionBlock) {
this.results.add(exceptionBlock)
this.title.exceptions++
this.mainGroup.title.testTotalExceptions++
this.updateTitle()
}
this.addSubGroup = function(groupBlock) {
this.results.add(groupBlock)
this.count++
if(this.countBlock !== undefined)
this.countBlock.count = this.count
this.title.total++
updateCountSuccess(this) // must be run before updateTitle (because it modifies info updateTitle relies on)
this.updateTitle()
}
this.end = function(time) {
//updateCountSuccess(this, true) // must be run before groupEnded is set (because it relies on groupEnded being false at this point)
if(this.expected !== undefined && !(this.count === this.expected)) this.mainGroup.title.testTotalFailures++
this.groupEnded = true
this.updateTitle()
if(!(this instanceof MainGroup)) {
this.title.add(Text('timeElapsed', ' took '+(time - this.startTime)+'ms'))
}
}
this.updateTitle = function() {
var success = this.title.passed === this.title.total && this.title.exceptions === 0
&& (this !== this.mainGroup || this.title.testTotalFailures === 0 && this.title.testTotalExceptions === 0)
this.results.visible = !success
var parts = [this,this.results,this.title]// things to set success on (since $state styling is currently so limited, you have to set it on everything that needs a style)
if(this instanceof MainGroup) {
parts.push(this.title.inner)
}
var ended = this.mainGroup.ended
parts.forEach(function(block) {
block.state.set("success", success)
block.state.set("late", ended)
})
if(this.parentGroup !== undefined) this.parentGroup.updateTitle()
}
})
// figure out if count succeeded and update the main group and the countblock state
function updateCountSuccess(that) {
if(that.expected !== undefined) {
var countSuccess = that.count === that.expected
that.countBlock.state.set("success", countSuccess)
if(that.groupEnded) that.countBlock.results.state.set("late", true)
if(countSuccess) {
that.mainGroup.title.testTotalPasses++
that.title.passed++
if(that.groupEnded) {
that.mainGroup.title.testTotalFailures--
that.groupEndCountSubtracted = true // marks that failures were subtracted after the test finished (so successes can be later subtracted correctly if need be)
}
} else if(that.groupEndCountSubtracted || that.count - 1 === that.expected) {
that.title.passed--
that.mainGroup.title.testTotalPasses--
if(that.groupEnded) {
that.mainGroup.title.testTotalFailures++
}
}
}
}
var MainGroup = proto(Group, function(superclass) {
this.name = "MainGroup"
this.createTitleBar = function(groupTitle) {
this.title = MainBar(groupTitle)
this.add(this.title)
}
this.build = function(groupTitle, time) {
superclass.build.call(this, this,groupTitle,time)
this.style = mainGroupStyle
var mainTitle = Text('mainTitle', groupTitle)
this.addAt(0, mainTitle)
this.add(this.pendingText=Text("Pending..."))
mainTitle.on('click', function() {
this.results.visible = !this.results.visible
}.bind(this))
}
this.endTest = function(type, time) {
if(type === 'timeout')
this.add(Text('timeout', "The test timed out!"))
this.pendingText.visible = false
this.updateTitle()
this.testTotalTime = getTimeDisplay(time - this.startTime)
this.title.takenText.text = "Took "
this.ended = true
}
})
var Text = proto(OriginalText, function() { // doing this cause i'm to lazy to update blocks.js right now
this.defaultStyle = Style({
whiteSpace: 'pre-wrap'
})
})
var GroupTitle = proto(Line, function() {
this.name = "GroupTitle"
this.build = function(title) {
this.totalNode = Text('0')
this.passedNode = Text('0')
this.exceptionsNode = Text('0')
if(title !== undefined) {
this.add(Text(title+": "))
}
this.add(this.passedNode, Text('/'), this.totalNode, Text(' and '), this.exceptionsNode, Text(" exceptions "))
}
;['total','passed','exceptions'].forEach(function(property) {
Object.defineProperty(this, property, {
get: function() { return parseInt(this[property+"Node"].text)},
set: function(v) {
this[property+"Node"].text = v
if(property === 'total' && this.totalPlural) {
if(v == 1) this.totalPlural.visible = false
else this.totalPlural.visible = true
}
}
})
}.bind(this))
})
var MainBar = proto(GroupTitle, function() {
this.name = "MainBar"
/*override*/ this.build = function(title) {
this.totalNode = Text('0'); this.totalPlural = Text('s')
this.passedNode = Text('0')
this.exceptionsNode = Text('0') // unused, but needed to match the interface of GroupTitle
this.testTotalPassesNode = Text('0'); this.testTotalPassesPlural = Text('es')
this.testTotalFailuresNode = Text('0'); this.testTotalFailuresPlural = Text('s')
this.testTotalExceptionsNode = Text('0'); this.testTotalExceptionsPlural = Text('s')
this.testTotalTimeNode = Text('0')
// used temporarily to approximate the time when counting up on-the-fly
// will be replaced by the time coming from deadunitCore's events at the end
this.temporaryStartTime = Date.now()
this.inner = Container('inner', []) // outer used for styling)
this.add(this.inner)
if(title !== undefined) {
this.inner.add(Text('title', title), Text(' - '))
}
this.inner.add(
this.passedNode, Text('/'), this.totalNode, Text(' successful test'),this.totalPlural,Text('. '),
Container('passes', this.testTotalPassesNode, Text(" pass"), this.testTotalPassesPlural), Text(", "),
Container('failures', this.testTotalFailuresNode, Text(" failure"),this.testTotalFailuresPlural), Text(", and "),
Container('exceptions', this.testTotalExceptionsNode, Text(" exception"), this.testTotalExceptionsPlural), Text(". "),
Container('time', this.takenText=Text("Has taken "), this.testTotalTimeNode, Text(".")),
Container('clickText', Text("click on this bar"))
)
}
;['testTotalPasses','testTotalFailures','testTotalExceptions','testTotalTime'].forEach(function(property) {
Object.defineProperty(this, property, {
get: function() { return parseInt(this[property+"Node"].text)},
set: function(v) {
this[property+"Node"].text = v
if(property!=='testTotalTime') {
this.testTotalTime = getTimeDisplay(Date.now() - this.temporaryStartTime)
if(v == 1) this[property+'Plural'].visible = false
else this[property+'Plural'].visible = true
}
}
})
}.bind(this))
})
// a line of result text
var ResultLine = proto(Line, function() {
this.name = "ResultLine"
this.build = function(resultText, sourceLines, file, line, column, expected, actual) {
this.resultTextNode = Text(resultText)
var location = Container('location',[Text("["+file+' '), Text('line',line), Text(":"+column+'] ')])
this.add(this.resultTextNode, location, Text(sourceLines))
this.expectedAndActual = Container('expectedAndActual')
this.add(this.expectedAndActual)
if(expected !== undefined) {
this.expectedAndActual.add(Text(" Expected "), Text('expected', utils.valueToMessage(expected)))
}
if(actual !== undefined) {
if(expected !== undefined)
this.expectedAndActual.add(Text(","))
this.got = Text("actual", utils.valueToMessage(actual))
this.expectedAndActual.add(Text(" Got "), this.got)
}
}
})
var Assert = proto(Line, function() {
this.name = "Assert"
this.successText = "Ok! "
this.failText = "Fail: "
this.build = function(sourceLines, file, line, column, late, expected, actual, success) {
var text = success?this.successText:this.failText
this.results = ResultLine(text, sourceLines, file, line, column, expected, actual)
this.add(this.results)
var that = this
this.state.on('change', function() {
that.results.expectedAndActual.visible = !that.state.subject.success
})
this.state.set("success", success)
this.results.state.set("success", success)
if(late) this.results.state.set("late", true)
}
})
var Exception = proto(Line, function() {
this.name = "Exception"
this.build = function(error, late) {
if(late) this.state.set("late", true)
var exceptionText = Text(utils.errorToString(error))
this.add(exceptionText)
}
})
var Count = proto(Assert, function(superclass) {
this.name = "Count"
this.build = function(sourceLines, file, line, column, late, expected) {
superclass.build.call(this, sourceLines, file, line, column, late, expected, 0, false)
this.expected = expected
}
Object.defineProperty(this, 'count', {
get: function() {
return this.results.got.text
}, set: function(count) {
this.results.got.text = count
if(count === this.expected) {
this.results.resultTextNode.text = this.successText
} else {
this.results.resultTextNode.text = this.failText
}
this.results.state.set("success", count === this.expected)
}
})
})
var Log = proto(Line, function() {
this.name = "Log"
this.build = function(values, late) {
if(late) this.state.set("late", true)
values.forEach(function(v) {
this.add(Text(utils.valueToString(v)))
}.bind(this))
}
})
function getTimeDisplay(milliseconds) {
if(milliseconds > 1000) {
return Math.floor(milliseconds/1000)+'s'
} else {
return milliseconds+'ms'
}
}