Skip to content

Instantly share code, notes, and snippets.

@mikeobrien
Created March 12, 2012 18:56
Show Gist options
  • Save mikeobrien/2023964 to your computer and use it in GitHub Desktop.
Save mikeobrien/2023964 to your computer and use it in GitHub Desktop.
window.error = (message) ->
console.log message
phantom.exit 1
#window.onerror = (message) ->
# console.log message
# phantom.exit 1
Date.prototype.elapsed = -> (new Date() - @) / 1000;
String.prototype.left = (length) -> @substr 0, length
String.prototype.right = (length) -> @substr @length - length
String.prototype.startsWith = (text) -> @left(text.length).toLowerCase() == text.toLowerCase()
String.prototype.endsWith = (text) -> @right(text.length).toLowerCase() == text.toLowerCase()
String.prototype.count = (text) -> @split(text).length - 1
String.prototype.repeat = (count) -> Array(count + 1).join @
String.prototype.urlEncode = -> @.replace(/\&/g, "&amp;").replace(/</g, "&lt;").replace(/\>/g, "&gt;").replace(/\"/g, "&quot;").replace(/\'/g, "&apos;")
class Path
@tempFilename: (extension) -> "~#{Math.random().toString().substr(2)}.#{extension}"
@normalizePath: (path) -> path.replace(/\\/g, '/')
@join: (paths...) -> @normalizePath paths.join('/').replace(/\/\//g, '/')
@normalizeDirectory: (path) ->
path = @normalizePath(path)
if path.endsWith('/') and path.count('/') > 1 then path.left(path.length - 1) else path
@getDirectory: (path) ->
path = @normalizePath(path)
path.left path.lastIndexOf('/')
@getFilename: (path) ->
path = @normalizePath(path)
path.substr path.lastIndexOf('/') + 1
@getFilenameWithoutExt: (filename) ->
if filename.indexOf('.') > 0 then filename.left(filename.lastIndexOf('.')) else filename
@getPathWithoutFileExt: (path) ->
directory = @getDirectory(path)
filename = @getFilenameWithoutExt(@getFilename(path))
@join directory, filename
@getRootPath: (paths...) ->
if paths.length == 0 then return ''
if paths.length == 1 then return paths[0]
path = paths[0]
while (p for p in paths when p.startsWith(path)).length < paths.length
path = path.left(path.lastIndexOf('/'))
@normalizeDirectory path
@excludeDirectories: (paths, exclusions) ->
paths.filter((path) -> !exclusions.some((directory) -> path.startsWith("#{directory}/")))
@getRelativePath: (root, path) ->
if !path
path = root
root = require('fs').workingDirectory
root = @normalizeDirectory(root)
path = @normalizeDirectory(path)
if path.left(root.length) == root then return path.substr root.length + 1
rootPath = @getRootPath root, path
if rootPath.length == 0 then error "Path '#{path}' is not relative to '#{root}'."
@join('../'.repeat(root.count('/') - rootPath.count('/')), path.substr(rootPath.length + 1))
@getAbsolutePath: (path) -> if path.match(/^\w:[\\/]/) then path else @join(require('fs').workingDirectory, path)
@toRegex: (object) ->
if object instanceof RegExp then return object
if !arguments.callee.reserved
reserved = ['/', '.', '+', '|', '(', ')', '[', ']', '{', '}', '\\']
arguments.callee.reserved = new RegExp('(\\' + reserved.join('|\\') + ')', 'g')
new RegExp("^#{object.replace(arguments.callee.reserved, '\\$1').replace('*', '.*').replace('?', '.')}$")
@search: (path, filter) ->
path = @normalizePath(path)
filter = @toRegex filter
fs = require 'fs'
results = ({ name: object, path: @join(path, object) } for object in fs.list(path))
dirs = (object for object in results when fs.isDirectory(object.path))
files = (object.path for object in results when fs.isFile(object.path) and object.name.match(filter))
files = files.concat(@search(dir.path, filter)) for dir in dirs when dir.name != '.' and dir.name != '..'
files
class Config
constructor: (args) ->
fs = require('fs')
@path = fs.workingDirectory
@appFilter = 'main.js'
@testsPath = fs.workingDirectory
@testFilter = '*.spec.js'
@requirePath = 'require.js'
@jasminePath = 'jasmine.js'
@outputPath = 'report'
@output = 'xml'
@scriptPaths = []
@modulePaths = []
@autotest = false
@autotestFrequency = 2000
name = null
for arg in args
if arg.startsWith '--'
switch arg
when '--auto-test' then @autotest = true
else name = arg
else
if name == null then @path = arg
else
switch name
when '--app-filter' then @appFilter = arg
when '--tests-path' then @testsPath = arg
when '--test-filter' then @testFilter = arg
when '--require-path' then @requirePath = arg
when '--jasmine-path' then @jasminePath = arg
when '--output-path' then @outputPath = arg
when '--output' then @output = arg
when '--script-path' then @scriptPaths.push arg
when '--module-path' then @modulePaths.push arg
when '--auto-test-frequency' then @autotestFrequency = parseInt(arg) * 1000
else continue
name = null
class Page
constructor: ->
@page = require('webpage').create()
@page.settings.localToRemoteUrlAccessEnabled = true
@page.onResourceRequested = (request) => @handleEvent { message: { message: "Loading #{request.url}"} }
@page.onConsoleMessage = (message, line, source) => @handleEvent { message: { message: message, line: line, source: source} }
handleEvent: (event) ->
console.log JSON.stringify(event)
return true
monitorEvents: ->
result = true
while event = @page.evaluate(-> window.events.shift())
try
result = @handleEvent(event)
catch error
result = @handleEvent({ error: { message: error } })
@eventMonitor = setTimeout (=> @monitorEvents()), 100 unless !result
stopEventMonitor: ->
if !@eventMonitor then return
clearTimeout(@eventMonitor)
@eventMonitor = null
apply: (args, func) ->
@page.evaluate "function() { (#{func.toString()}(#{args.map((x) -> JSON.stringify(x)).join(', ')})); }"
loadScripts: (scripts, onComplete) ->
if scripts.length == 0 then onComplete() else @page.includeJs(scripts.shift(), => @loadScripts(scripts, onComplete))
load: (scripts, onComplete) ->
@stopEventMonitor()
fs = require 'fs'
templatePath = Path.getAbsolutePath(Path.tempFilename('html'))
fs.write(templatePath, '<html><head></head><body></body></html>', 'w')
runnerUrl = "file://#{templatePath.substr(2)}"
@page.open runnerUrl, (status) =>
fs.remove templatePath
@page.evaluate ->
window.events = []
window.onerror = (message, source, line) ->
window.events.push({error: {message: message, source: source, line: line}})
@loadScripts scripts, =>
onComplete()
@monitorEvents()
close: ->
@stopEventMonitor()
@page.release()
class JasmineTestRunner extends Page
constructor: (@scripts, @onComplete) -> super()
handleEvent: (event) ->
if event == null then return true
if event.error
console.log "An error occured at #{event.error.source}:#{event.error.line}: #{event.error.message}"
phantom.exit 1
if event.message then console.log event.message.message
if event.jasmine
jasmine = event.jasmine
if jasmine.spec
console.log "Test '#{jasmine.spec.fullName}' #{if !jasmine.spec.failed then 'SUCCEEDED' else 'FAILED'}."
if jasmine.summary
succeded = (suite for suite in jasmine.summary when suite.failed != 0).length == 0
console.log "Completed #{if succeded then 'successfully' else 'with failure(s).'}."
@onComplete(@, jasmine.summary)
return false
return true
run: (baseUrl, paths, modules) ->
@load @scripts, =>
@page.evaluate ->
define 'JasmineReporter', ->
class JasmineReporter
elapsed: (startTime, endTime) -> (endTime - startTime) / 1000;
reportSpecStarting: (spec) ->
spec.summary = spec.summary ? { start: new Date() }
spec.suite.summary = spec.suite.summary ? { start: new Date() }
reportSpecResults: (spec) ->
results = spec.results()
spec.summary.failed = !results.passed()
spec.summary.fullName = spec.getFullName()
spec.summary.name = spec.description
spec.summary.end = new Date()
spec.summary.duration = @elapsed(spec.summary.start, new Date())
results = results.getItems()
spec.summary.messages = ({ log: result.toString() } for result in results when result.type == 'log')
failures = (result for result in results when result.type == 'expect' and result.passed and !result.passed())
spec.summary.messages = spec.summary.messages.concat({ fail: failure.message } for failure in failures when failure.message)
spec.summary.messages = spec.summary.messages.concat({ stack: failure.trace.stack } for failure in failures when failure.trace.stack)
window.events.push { jasmine: { spec: spec.summary }}
reportSuiteResults: (suite) ->
specs = suite.specs()
suite.summary.name = suite.getFullName()
suite.summary.end = new Date()
suite.summary.duration = @elapsed(suite.summary.start, new Date())
suite.summary.specs = specs.map (x) -> x.summary
suite.summary.failed = (spec for spec in specs when spec.summary.failed).length
window.events.push { jasmine: { suite: suite.summary }}
reportRunnerResults: (runner) ->
window.events.push { jasmine: { summary: runner.suites().map((x) -> x.summary) }}
@apply [baseUrl, paths, modules], (baseUrl, paths, modules) ->
console.log 'Starting Jasmine test runner...'
require.config
baseUrl: baseUrl
paths: paths
urlArgs: "x=" + (new Date()).getTime()
require ['JasmineReporter'].concat(modules), (JasmineReporter) ->
console.log 'Running Jasmine tests...'
jasmineEnv = jasmine.getEnv()
jasmineEnv.updateInterval = 1000
jasmineEnv.addReporter new JasmineReporter()
jasmineEnv.execute()
class TestRunner
constructor: (basePath, @appFilter, testsPath, @testFilter, @requirePath, @jasminePath, @scriptPaths, @modulePaths, @reportWriter, @onComplete) ->
@basePath = Path.getAbsolutePath basePath
@testsPath = if !testsPath then @basePath else Path.getAbsolutePath testsPath
run: ->
summary =
start: new Date()
suites: []
console.log 'Starting test runner...'
console.log "Searching for #{@appFilter} under #{@basePath}..."
@appQueue = Path.search(@basePath, @appFilter).sort (a, b) -> b.length - a.length
if @appQueue.length == 0 then @complete(summary)
else @runNext(summary)
runNext: (summary) ->
if @appQueue.length == 0 then return @complete(summary)
appMain = @appQueue.pop()
appPath = Path.getDirectory(appMain)
relativeAppPath = Path.getRelativePath(appPath)
console.log '----------------------------------------------------------------------------'
console.log "Finding tests for #{appMain}"
getAbsoluteTestPath = (path) => Path.join(@testsPath, Path.getRelativePath(@basePath, path))
getRelativeTestPath = (path) => Path.getPathWithoutFileExt(Path.getRelativePath(appPath, path))
appTestsPath = getAbsoluteTestPath(appPath)
console.log "Looking for tests under #{appTestsPath}"
appTestPaths = Path.search(appTestsPath, @testFilter).map((x) -> getRelativeTestPath(x))
appTestPaths = Path.excludeDirectories(appTestPaths, @appQueue.map((x) => getRelativeTestPath(getAbsoluteTestPath(Path.getDirectory(x)))))
modulePaths = @modulePaths.map((x) -> Path.getPathWithoutFileExt(Path.getRelativePath(appPath, Path.getAbsolutePath(x))))
if appTestPaths.length == 0
console.log "No tests found."
return @runNext(summary)
console.log("Found test suite #{suite}") for suite in appTestPaths
requirePaths = /paths\s*:\s*(\{\s*[\s\S]*?\s*\})/m.exec(require('fs').read(appMain))
requirePaths = if requirePaths.length > 1 then JSON.parse(requirePaths[1]) else {}
onNext = (runner, suites) =>
suites = suites.slice()
suites.forEach((x) => x.path = Path.getRelativePath(appTestsPath))
summary.suites = summary.suites.concat(suites)
runner.close()
@runNext(summary)
runner = new JasmineTestRunner([@requirePath, @jasminePath].concat(@scriptPaths), onNext)
runner.run(relativeAppPath, requirePaths, appTestPaths.concat(modulePaths))
complete: (summary) ->
console.log '----------------------------------------------------------------------------'
summary.end = new Date()
summary.duration = summary.start.elapsed()
if summary.suites.length == 0
summary.specs = 0
summary.failed = 0
console.log 'Test runner did not find any tests!'
else
summary.specs = (suite.specs.length for suite in summary.suites).reduce (t, s) -> t + s
summary.failed = (suite.failed for suite in summary.suites).reduce (t, s) -> t + s
console.log "Test runner completed #{if summary.failed == 0 then 'successfully' else 'with failures'} yo."
console.log '----------------------------------------------------------------------------'
@reportWriter.save summary
@onComplete summary
class Writer
constructor: (path) ->
@path = Path.getAbsolutePath(path)
@fs = require 'fs'
deleteFile: -> if @fs.exists(@path) then @fs.remove @path
write: (data) -> @fs.write @path, data, 'a'
writeln: (data) -> @write data + "\r"
class XmlWriter extends Writer
constructor: (path) -> super(path)
save: (summary) ->
@deleteFile()
@write """<testsuites tests="#{summary.specs}" failures="#{summary.failed}" disabled="0" """
@writeln """errors="0" time="#{summary.duration}" timestamp="#{summary.end.toISOString()}">"""
for suite in summary.suites
@write """\t<testsuite name="#{suite.name}" tests="#{suite.specs.length}" failures="#{suite.failed}" """
@writeln """disabled="0" errors="0" time="#{suite.duration}">"""
for spec in suite.specs
@writeln """\t\t<testcase name="#{spec.name}" status="run" time="#{spec.duration}" classname="#{spec.name}">"""
for message in spec.messages when message.fail or message.stack
@write """\t\t\t<failure message="#{(message.log ? message.fail ? '').urlEncode()}" type="">"""
@writeln """<![CDATA[#{message.stack ? message.fail ? ''}]]></failure>"""
@writeln '\t\t</testcase>'
@writeln '\t</testsuite>'
@writeln '</testsuites>'
class HtmlWriter extends Writer
constructor: (path, @autoReload, @autoReloadInterval) -> super(path)
save: (summary) ->
@deleteFile()
@writeln """
<html><head><title>Jasmine Spec Runner</title>
<style>
body { font-family: "Helvetica Neue Light", "Lucida Grande", "Calibri", "Arial", sans-serif; }
.banner { color: #303; background-color: #fef; padding: 5px; }
.logo { float: left; font-size: 1.1em; padding-left: 5px; }
.options { text-align: right; font-size: .8em; }
.suite { border: 1px outset gray; margin: 5px 0; padding-left: 1em; } .suite .suite { margin: 5px; }
.suite.passed { background-color: #dfd; } .suite.failed { background-color: #fdd; }
.spec { margin: 5px; padding-left: 1em; clear: both; }
.spec.failed, .spec.passed, .spec.skipped { padding-bottom: 5px; border: 1px solid gray; }
.spec.failed { background-color: #fbb; border-color: red; }
.spec.passed { background-color: #bfb; border-color: green; }
.finished-at { padding-left: 1em; font-size: .6em; }
.messages { border-left: 1px dashed gray; padding-left: 1em; padding-right: 1em; }
.resultMessage span.result { display: block; line-height: 2em; color: black; }
.resultMessage .mismatch { color: black; }
.stackTrace { white-space: pre; font-size: .8em; margin-left: 10px; max-height: 5em;
overflow: auto; border: 1px inset red; padding: 1em; background: #eef; }
.passed { background-color: #cfc; display: none; } .failed { background-color: #fbb; }
.show-passed .passed, .show-skipped .skipped { display: block; }
.runner { border: 1px solid gray; display: block; margin: 5px 0; padding: 2px 0 2px 10px; }
</style>
<script>
#{if @autoReload then "window.setInterval(function() {location.reload();}, #{@autoReloadInterval});" else ''}
function toggleTestVisibility(enabled) {
reporter = document.getElementById('reporter');
if (enabled) { reporter.className += ' show-passed'; }
else { reporter.className = reporter.className.replace(/ show-passed/, ''); }
}
</script>
</head>
<body>
<div class="jasmine_reporter" id="reporter">
<div class="banner">
<div class="logo"><span class="title">Jasmine</span></div>
<div class="options">
<input id="showPassed" type="checkbox" onclick="toggleTestVisibility(this.checked)">
<label for="showPassed"> passed </label>
</div>
</div>
<div class="runner #{if summary.failed == 0 then 'passed' else 'failed'}">
<span>#{summary.specs} spec#{if summary.specs != 1 then 's' else ''},
#{summary.failed} failure#{if summary.failed != 1 then 's' else ''} in #{summary.duration}s</span>
<span class="finished-at">Finished at #{summary.end}</span>
</div>
"""
for suite in summary.suites
@writeln """<div class="suite #{if suite.failed == 0 then 'passed' else 'failed'}"><span>#{suite.name} (#{suite.path})</span>"""
for spec in suite.specs
@writeln """<div class="spec #{if spec.failed then 'failed' else 'passed'}"><span>#{spec.name}</span><div class="messages">"""
for message in spec.messages
type = if message.stack then 'stackTrace' else 'resultMessage ' + (if message.log then 'log' else 'fail')
@writeln """<div class="#{type}">#{(message.log ? message.stack ? message.fail).urlEncode()}</div>"""
@writeln '</div></div>'
@writeln "</div>"
@writeln "</div></body></html>"
config = new Config(phantom.args)
reportWriter =
switch config.output
when 'xml' then new XmlWriter(config.outputPath)
when 'html' then new HtmlWriter(config.outputPath, config.autotest, config.autotestFrequency)
run = (onComplete) ->
runner = new TestRunner(config.path, config.appFilter, config.testsPath, config.testFilter, config.requirePath,
config.jasminePath, config.scriptPaths, config.modulePaths, reportWriter, onComplete)
runner.run()
if config.autotest
runTests = -> run (summary) -> window.setTimeout(runTests, config.autotestFrequency)
runTests()
else run (summary) -> phantom.exit if summary.failed == 0 then 0 else 1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment