siesta-lite
Version:
Stress-free JavaScript unit testing and functional testing tool, works in NodeJS and browsers
646 lines (474 loc) • 25.3 kB
JavaScript
/*
Siesta 5.6.1
Copyright(c) 2009-2022 Bryntum AB
https://bryntum.com/contact
https://bryntum.com/products/siesta/license
*/
Class('Siesta.Launcher.Dispatcher', {
does : [
Siesta.Launcher.Dispatcher.TaskQueue,
Siesta.Launcher.Dispatcher.Reporter,
Siesta.Launcher.Role.CanPrintWithLauncher,
Siesta.Launcher.Role.CanLaunchSimulatorServer,
Siesta.Launcher.Role.CanLaunchInstrumentationProxy,
Siesta.Util.Role.CanGetType
],
has : {
launcher : { required : true },
// an array of runners created by the launcher for this dispatcher
// idea is that some of them can fail on the "setup" step, so those who successeded
// will be in the "runners" attribute
givenRunners : { required : true },
// code was originally written for multiple runners, then it turned out, its never used
// (always only one runner), but we still keep it this way, in case we go crazy and will
// support several runners, like BS and SL in the same time
runners : Joose.I.Array,
options : { is : 'rw', init : Joose.I.Object },
projectUrl : { required : true },
testSuiteName : null,
// is used for JUnit report (only)
hostName : null,
startDate : null,
endDate : null,
allDoneCallback : null,
screenshotCompareConfig : null,
reRunFailed : false,
projectConfig : null,
streamAssertions : false,
printState : Joose.I.Object,
breakOnFail : false,
forceExit : false,
wsServer : null,
hasNativeSimulation : false
},
methods : {
addRunner : function (runner) {
this.runners.push(runner)
runner.dispatcher = this
runner.maxWorkers = this.options[ 'max-workers' ] || runner.maxWorkers
// the `page-size` name is deprecated
runner.pagesPerChunk = this.options[ 'chunk-size' ] || this.options[ 'page-size' ] || runner.pagesPerChunk
},
// promised method
finalizeCommand : function (command, page, result) {
var me = this
return page.executeSmallScriptPromised(
"return Siesta.my.activeHarness.onCommandDone(" + command.id + ", " + JSON.stringify(result) + ")"
).then(function (e) {
if (e) me.printError("ERROR DURING COMMAND FINALIZATION: " + e)
}, function (e) {
me.printError("ERROR DURING COMMAND FINALIZATION: " + e)
})
},
// promised method
// all commands supposed to resolve to object with { success : true/false }, { error : String } (if failed)
// plus some other properties
executeCommandFromPage : function (command, page) {
var me = this
me.debug("Received command: " + JSON.stringify(command))
if (!page[ command.name ]) {
return me.finalizeCommand({ success : false, error : 'unknown command' })
}
return page[ command.name ](command).then(function (res) {
return me.finalizeCommand(command, page, res)
}, function (e) {
me.printError(JSON.stringify(e))
return me.finalizeCommand(command, page, { success : false, error : e + '' })
})
},
canPrintUpdateImmediately : function (update) {
return !this.reRunFailed || this.options.verbose || update.type == 'Siesta.Result.Diagnostic'
|| update.type == 'Siesta.Result.Assertion' && (update.passed || update.isTodo)
},
// promised method
consumeTestStateUpdate : function (state, page) {
var me = this
if (me.forceExit) return 'force_exit'
var failed = false
if (this.streamAssertions) {
var eventLog = state.eventLog
eventLog && Joose.A.each(eventLog, function (event) {
if (event.isLog) {
if (me.typeOf(event.data) == 'String')
me.print(event.data)
else
me.warn(event.data.text)
} else if (event.isUpdate) {
var update = me.consumeStreamedUpdate(event.data, page)
if (update.type == 'Siesta.Result.Assertion' && !update.passed && !update.isTodo) failed = true
// top level Siesta.Result.SubTest instance
if (update.type == 'Siesta.Result.SubTest' && !update.parentId) me.printTestHeader(update)
if (me.canPrintUpdateImmediately(update)) me.printStreamedUpdate(update, me.printState)
} else if (event.isResult) {
var el = me.consumeTestResult(event.data, page)
if (el.result.ERROR || !el.result.passed && !el.result.isTodo) failed = true
if (!me.reRunFailed || el.canPrintResultImmediately()) me.printTestResult(el, false, true)
}
})
} else {
var log = state.log
log && Joose.A.each(log, function (logElement) {
if (me.typeOf(logElement) == 'String')
me.print(logElement)
else
me.warn(logElement.text)
})
var testResults = state.testResults
testResults && Joose.A.each(testResults, function (testResult) {
var el = me.consumeTestResult(testResult, page)
if (el.result.ERROR || !el.result.passed && !el.result.isTodo) failed = true
// do not print failed tests in case of "reRunFailed" is enabled
if (!me.reRunFailed || el.canPrintResultImmediately()) me.printTestResult(el)
})
}
if (me.options.coverage) {
var nyc = this.getNyc()
state.coverageResult && state.coverageResult.forEach(function (coverage) {
me.nycWriteCoverageFile(nyc, coverage)
})
}
var commands = state.commands
var promise = Promise.resolve()
// a command has been send from the test page
commands && Joose.A.each(commands, function (command) {
promise = promise.then(function () {
return me.executeCommandFromPage(command, page)
})
})
return promise.then(function () {
if (failed && me.breakOnFail) {
me.forceExit = true
return 'force_exit'
}
// indicates the last state available
if (state.exitStatus != null) {
if (state.exitStatus == 'focus_lost') {
return 'focus_lost'
}
return 'all_done'
}
})
},
launchPage : function (page, chunkTask, needToOpenHarness, runner) {
var me = this
if (needToOpenHarness)
page.openHarness(me.projectUrl, function (error) {
if (error) {
me.releaseChunkTask(chunkTask)
page.close().then(function () {
me.launchRunner(runner)
})
} else
runChunk()
})
else
runChunk()
function runChunk() {
page.runChunk(chunkTask, function (e, notLaunchedById) {
if (me.forceExit) {
if (me.allDoneCallback) {
me.allDoneCallback({ exitCode : 1, lastPage : page })
me.allDoneCallback = null
}
return
}
me.releaseChunkTask(chunkTask, notLaunchedById)
if (me.allDone()) {
var reallyAllDone = me.onAllTestsProcessed(page)
// note, that the last page is kept opened for coverage report generation
if (reallyAllDone) {
return
} else {
me.projectConfig.isRerunningFailedTests = true
}
}
// ideally we need to properly close the page, before starting a new one (to conform to max-workers limitation)
// however, in practice "page.close()" can just hang, so in such cases we just call "page.close()"
// and continue
if (e && page.pageShouldNotBeUsedAfterException(e)) {
me.launchRunner(runner)
page.close()
} else
page.close().then(function () {
me.launchRunner(runner)
})
})
}
},
onAllTestsProcessed : function (lastPage) {
var me = this
// reset the failed tests statuses
if (me.reRunFailed) {
var res = me.reviseFailedTests()
me.projectConfig.failedTestsCount = res.failedCount
me.projectConfig.totalTestsCount = res.totalCount
}
if (me.allDone()) {
if (me.reRunFailed) {
me.forEachTestElement(function (el) {
if (!el.resultPrinted) me.printTestResult(el, true)
})
}
me.allDoneCallback({ exitCode : me.allPassed() ? 0 : 1, lastPage : lastPage })
return true
}
return false
},
launchRunner : function (runner, veryFirstPage) {
var me = this
if (me.launcher.shutDownStarted || runner.isPageCreationPaused) return
me.debug("Launch runner, pagesCount: " + runner.pageCount + ", max : " + runner.maxWorkers + ", pages left: " + runner.getPageIdList())
while (veryFirstPage || runner.canCreatePage()) {
var chunkTask = this.getChunkTask(runner.pagesPerChunk)
if (!chunkTask) break
if (veryFirstPage) {
this.launchPage(veryFirstPage, chunkTask, false, runner)
veryFirstPage = null
} else {
var pageRequestAccepted = runner.requestPage(function (page, chunkTask) {
if (page)
me.launchPage(page, chunkTask, true, runner)
else {
me.releaseChunkTask(chunkTask, true)
if (runner.pageCreationFailuresCount >= 3) {
me.printError("Page creation failed after retry, test suite can not continue execution")
me.launcher.gracefulShutdown()
return
} else {
me.printError("Page creation has failed, retry in 5s")
runner.pausePageCreation(5000, function () {
me.launchRunner(runner)
})
}
}
}, chunkTask)
if (!pageRequestAccepted) {
me.releaseChunkTask(chunkTask, true)
break
}
}
}
},
launch : function (firstRunner, veryFirstPage) {
var me = this
Joose.A.each(this.runners, function (runner) {
if (runner == firstRunner)
me.launchRunner(runner, veryFirstPage)
else
me.launchRunner(runner)
})
},
getTotalNumberOfPagesIncludingReserved : function () {
var count = 0
Joose.A.each(this.runners, function (runner) {
count += runner.pageCount + runner.reservedPageCount
})
return count
},
forEachAssertion : function (testInfo, func, scope, options, parentTests) {
options = options || {}
scope = scope || this
parentTests = parentTests || []
parentTests.push(testInfo)
var ignoreTodoAssertions = options.ignoreTodoAssertions
var includeDiagnostic = options.includeDiagnostic
for (var i = 0; i < testInfo.assertions.length; i++) {
var assertion = testInfo.assertions[ i ]
if (assertion.type == 'Siesta.Result.Assertion' && (!assertion.isTodo || !ignoreTodoAssertions))
if (func.call(scope, assertion, parentTests) === false) return false
if (assertion.type == 'Siesta.Result.Diagnostic' && includeDiagnostic)
if (func.call(scope, assertion, parentTests) === false) return false
if (assertion.type == 'Siesta.Result.SubTest')
if (this.forEachAssertion(assertion, func, scope, options, parentTests.slice()) === false) return false
}
},
start : function () {
this.startDate = new Date()
var me = this
var options = me.options
var upstreamProxyConfig = options[ 'proxy-host' ] ? { host : options[ 'proxy-host' ], port : options[ 'proxy-port' ] } : null
// for BS we don't need to use upstream proxy, as it is already passed to BS tunnel as --proxy-host --proxy-port
// BS tunnel uses --local-proxy-host, --local-proxy-port for browser traffic
if (options.coverage && options.bs) upstreamProxyConfig = null
// setup the instrumentation proxy as the 1st thing, since its port is needed to Puppeteer runner for example
var cont = options.coverage ? me.setupInstrumentationProxy(upstreamProxyConfig, options.coverage) : Promise.resolve()
return cont.then(function () {
var promises = []
Joose.A.each(me.givenRunners, function (runner) {
runner.dispatcher = me
promises.push(runner.setup())
})
return Promise.all(promises)
}).then(function (results) {
Joose.A.each(results, function (result) {
if (result instanceof Error)
me.warn("Error setting up the runner: " + result)
else
me.addRunner(result)
})
}).then(function () {
if (me.runners.length == 0) {
me.printError("No runners available")
return { exitCode : 3 }
} else {
me.debug("Dispatcher setup starting")
return me.setup(me.runners[ 0 ]).then(function (setupRes) {
me.debug("Dispatcher setup completed, launching the suite")
var config = setupRes.config
var firstRunner = setupRes.firstRunner
var firstPage = setupRes.firstPage
return new Promise(function (resolve, reject) {
me.allDoneCallback = resolve
me.onTestSuiteStart(config.userAgent, config.platform)
me.launch(firstRunner, firstPage)
})
}, function (e) {
me.printError("Setup failed: " + e)
return { exitCode : e.errCode || 3 }
})
}
}).then(function (res) {
var exitCode = res.exitCode
var lastPage = res.lastPage
me.debug("Recevied results for all tests in the suite, proceeding to finalization")
me.endDate = new Date()
if (exitCode == 0 || exitCode == 1) {
me.onTestSuiteEnd()
return me.processReports(lastPage).then(function () {
me.debug("Reports processed (if any)")
if (lastPage)
return lastPage.close().then(function () {
return exitCode
})
else
return exitCode
})
} else
return exitCode
})
},
processReports : async function (page) {
var me = this
me.debug("Generating reports (if any)")
var options = this.options
var launcher = this.launcher
var reportFile = options.reportFile
var reportFormat = options.reportFormat
if (reportFile) {
Joose.A.each(reportFile, function (value, index) {
launcher.saveReport(
reportFormat[ index ],
reportFile[ index ],
me.generateReport({ format : reportFormat[ index ] })
)
})
}
if (options.coverage && this.launcher.manuallyProcessCoverageResults) {
await this.nycReport(this.getNyc(), (path) => {
const fs = require('fs')
if (fs.existsSync(path))
// this is for the pre-instrumented codebase
return fs.readFileSync(path, 'utf8')
else
return this.instrumentedSources[ path ] || 'No sources available for ' + path
})
}
return Promise.resolve()
},
randomizeArray : function (array) {
var randomArray = new Array(array.length)
array.forEach(function (el, index) {
randomArray[ index ] = { index : index, random : Math.random() }
})
randomArray.sort(function (a, b) { return a.random - b.random })
var result = []
randomArray.forEach(function (el) {
result.push(array[ el.index ])
})
return result
},
setup : function (firstRunner) {
var me = this
var mySiestaVersion = me.launcher.getSiestaVersion()
var options = me.options
return new Promise(function (RESOLVE, REJECT) {
firstRunner.requestPage(function (page, arg, e) {
if (page) {
page.openHarness(me.projectUrl, function (err) {
if (err) {
page.close().then(function () {
var e = new Error("Error while opening project page: " + err)
e.errCode = 5
REJECT(e)
})
} else {
page.getConfigInfo(options[ 'include' ], options[ 'exclude' ], options[ 'filter' ], function (e, config) {
if (e) {
page.close().then(function () {
REJECT(new Error("Error getting the test suite information: " + e))
})
return
}
if (config.VERSION && mySiestaVersion && config.VERSION != mySiestaVersion) {
me.printError("Siesta version on project page [" + config.VERSION + "] does not match Siesta version of the launcher [" + mySiestaVersion + "]")
page.close().then(function () {
REJECT(new Error("Siesta versions mismatch"))
})
return
}
me.breakOnFail = config.breakOnFail
if (me.breakOnFail) me.reRunFailed = false
me.hostName = config.hostName
me.testSuiteName = config.title
me.screenshotCompareConfig = config.screenshotCompareConfig
me.structure = config.structure
var desc = config.descriptors
if (!desc.sharedContextGroups.length && !desc.mustRunSequential.length) {
var style = me.style()
me.printError(
"Found no tests to run. Check your project file, glob pattern,\n"
+ style.bold("--include") + ", " + style.bold("--exclude") + " and " + style.bold("--filter") + " command line options.\n"
+ "For help, launch with " + style.bold("--help")
)
page.close().then(function () {
var err = new Error("No tests to run")
err.errCode = 4
REJECT(err)
})
return
}
var maxAttempts = Number(options[ 'restart-attempts' ]) + 1
if (isNaN(maxAttempts)) maxAttempts = 2
var randomize = Boolean(options[ 'randomize-tests-order' ])
me.sharedContextGroups = Joose.A.map(desc.sharedContextGroups, function (group) {
return new Siesta.Launcher.Dispatcher.Group({
elements : randomize ? me.randomizeArray(group.items) : group.items,
maxProcessedCount : maxAttempts
})
})
me.regularTestsGroup = new Siesta.Launcher.Dispatcher.Group({
elements : randomize ? me.randomizeArray(desc.mustRunSequential) : desc.mustRunSequential,
maxProcessedCount : maxAttempts
})
// TODO a global flag, saved on dispatcher instance
// should instead decide per page
me.hasNativeSimulation = config.hasNativeSimulation
var cont = config.hasNativeSimulation && me.launcher.sharedNativeSimulator ? me.setupNativeEventsSimulator() : Promise.resolve()
cont.then(
function () {
RESOLVE({ firstRunner : firstRunner, firstPage : page, config : config })
},
function (e) { REJECT('Error while setting up native events simulation websocket server: ' + e) }
)
})
}
})
// eof page.openHarness
} else
REJECT(new Error("Can't create first page, runner: " + firstRunner + ", exception: " + e))
})
})
}
// eof setup
}
})