import { format } from 'node:util'
import prettyMs from 'pretty-ms'
import type { Capabilities } from '@wdio/types'
import { Chalk, type ChalkInstance } from 'chalk'
import WDIOReporter, { TestStats, getBrowserName } from '@wdio/reporter'
import type { SuiteStats, HookStats, RunnerStats, Argument } from '@wdio/reporter'
import { buildTableData, printTable, getFormattedRows, sauceAuthenticationToken } from './utils.js'
import { ChalkColors, type SpecReporterOptions, type TestLink, type StateCount, type Symbols, State } from './types.js'
const DEFAULT_INDENT = ' '
interface CapabilitiesWithSessionId extends WebdriverIO.Capabilities {
sessionId: string
}
export default class SpecReporter extends WDIOReporter {
private _suiteUids = new Set()
private _indents = 0
private _suiteIndents: Record<string, number> = {}
private _orderedSuites: SuiteStats[] = []
private _consoleOutput = ''
private _suiteIndent = ''
private _preface = ''
private _consoleLogs: string[] = []
private _pendingReasons: string[] = []
private _originalStdoutWrite = process.stdout.write.bind(process.stdout)
private _addConsoleLogs = false
private _realtimeReporting = false
private _showPreface = true
private _suiteName = ''
private _isSuiteRetry = false
private _passingTestsSinceLastRetry = 0
private _stateCounts: StateCount = {
passed: 0,
failed: 0,
skipped: 0,
pending: 0,
retried: 0
}
private _symbols: Symbols = {
passed: '✓',
skipped: '-',
pending: '?',
failed: '✖',
retried: '↻'
}
private _chalk: ChalkInstance
private _onlyFailures = false
private _sauceLabsSharableLinks = true
constructor (options: SpecReporterOptions) {
* make spec reporter to write to output stream by default
*/
super(Object.assign({ stdout: true } as Record<string, unknown>, options))
this._symbols = { ...this._symbols, ...this.options.symbols || {} }
this._onlyFailures = options.onlyFailures || false
this._realtimeReporting = options.realtimeReporting || false
this._showPreface = options.showPreface !== false
this._sauceLabsSharableLinks = 'sauceLabsSharableLinks' in options
? options.sauceLabsSharableLinks as boolean
: this._sauceLabsSharableLinks
const processObj = process as any
if (options.addConsoleLogs || this._addConsoleLogs) {
processObj.stdout.write = (chunk: string, encoding: BufferEncoding, callback: ((err?: Error) => void)) => {
if (typeof chunk === 'string' && !chunk.includes('mwebdriver')) {
this._consoleOutput += chunk
}
return this._originalStdoutWrite(chunk, encoding, callback)
}
}
this._chalk = new Chalk(options.color === false ? { level:0 } : {})
}
* @param state state of test execution
* @param msg the message to print in terminal
* @returns colord value based on chalk to print in terminal
*/
setMessageColor(message:string, state?: State): string{
return this._chalk[this.getColor(state)](message)
}
onRunnerStart (runner: RunnerStats) {
this._preface = this._showPreface ? `[${this.getEnviromentCombo(runner.capabilities, false, runner.isMultiremote).trim()} #${runner.cid}]` : ''
}
onSuiteStart (suite: SuiteStats) {
this._suiteName = suite.file?.replace(process.cwd(), '')
this.printCurrentStats(suite)
this._suiteUids.add(suite.uid)
if (suite.type === 'feature') {
this._indents = 0
this._suiteIndents[suite.uid] = this._indents
} else {
this._suiteIndents[suite.uid] = ++this._indents
}
}
onSuiteEnd () {
this._indents--
this._isSuiteRetry = false
this._passingTestsSinceLastRetry = 0
}
onSuiteRetry(): void {
this._stateCounts.failed--
this._stateCounts.retried++
this._stateCounts.passed -= this._passingTestsSinceLastRetry
this._isSuiteRetry = true
this._passingTestsSinceLastRetry = 0
}
onHookEnd (hook: HookStats) {
this.printCurrentStats(hook)
if (hook.error) {
this._stateCounts.failed++
}
}
onTestStart () {
this._consoleOutput = ''
}
onTestPass (testStat: TestStats) {
this.printCurrentStats(testStat)
this._consoleLogs.push(this._consoleOutput)
this._stateCounts.passed++
if (!this._isSuiteRetry) {
this._passingTestsSinceLastRetry++
}
}
onTestFail (testStat: TestStats) {
this.printCurrentStats(testStat)
this._consoleLogs.push(this._consoleOutput)
this._stateCounts.failed++
}
onTestSkip (testStat: TestStats) {
this.printCurrentStats(testStat)
this._pendingReasons.push(testStat.pendingReason as string)
this._consoleLogs.push(this._consoleOutput)
this._stateCounts.skipped++
}
onTestPending(testStat: TestStats) {
this.printCurrentStats(testStat)
this._pendingReasons.push(testStat.pendingReason as string)
this._consoleLogs.push(this._consoleOutput)
this._stateCounts.pending++
}
onRunnerEnd (runner: RunnerStats) {
this.printReport(runner)
}
* Print the report to the stdout realtime
*/
printCurrentStats (stat: TestStats | HookStats | SuiteStats) {
if (!this._realtimeReporting) {
return
}
const title = stat.title, state = (stat as TestStats).state as State
const divider = '------------------------------------------------------------------'
const indent = (stat.type==='test') ?
`${DEFAULT_INDENT}${this._suiteIndent}` :
this.indent(stat.uid)
const suiteStartBanner = (stat.type === 'feature' || stat.type === 'suite' || stat.type === 'suite:start') ?
`${this._preface} ${divider}\n`+
`${this._preface} Suite started: \n`+
`${this._preface} » ${this._suiteName}\n` : '\n'
const content = stat.type === 'test'
? `${this._preface} ${indent}` +
`${this.setMessageColor(this.getSymbol(state), state)} ${title}` +
` » ${this.setMessageColor('[', state)} ${this._suiteName} ${this.setMessageColor(']', state)}`
: stat.type !== 'hook' ?
`${suiteStartBanner}${this._preface} ${title}` :
title
? `${this._preface} Hook executed: ${title}`
: undefined
* only send event upstream,
* - if we are in a child process
* - there is content to send
* - we are not running a unit test
*/
if (process.send && content && !process.env.WDIO_UNIT_TESTS) {
process.send({ name: 'reporterRealTime', content })
}
}
* Print the report to the screen
*/
printReport(runner: RunnerStats) {
if (runner.failures === 0 && this._onlyFailures === true) {
return
}
const duration = `(${prettyMs(runner._duration)})`
const preface = `[${this.getEnviromentCombo(runner.capabilities, false, runner.isMultiremote).trim()} #${runner.cid}]`
const divider = '------------------------------------------------------------------'
const results = this.getResultDisplay(preface)
if (results.length === 0 && runner.error) {
results.push(
this.setMessageColor(`${this.getSymbol(State.FAILED)} Failed to create a session:`, State.FAILED),
runner.error,
''
)
}
if (results.length === 0) {
return
}
const testLinks = runner.isMultiremote
? Object.entries(runner.capabilities).map(([instanceName, capabilities]: [string, CapabilitiesWithSessionId]) => this.getTestLink({
capabilities,
sessionId: capabilities.sessionId,
isMultiremote: runner.isMultiremote,
instanceName
})).filter((links) => links.length)
: this.getTestLink(runner)
const output = [
...this.getHeaderDisplay(runner),
'',
...results,
...this.getCountDisplay(duration),
...this.getFailureDisplay(),
...(testLinks.length
* if we have test links add an empty line
*/
? ['', ...testLinks]
: []
)
]
const prefacedOutput = this._showPreface ? output.map((value) => {
return value ? `${preface} ${value}` : preface
}) : output
this.write(`${divider}\n${prefacedOutput.join('\n')}\n`)
}
* get link to saucelabs job
*/
getTestLink ({ sessionId, isMultiremote, instanceName, capabilities }: TestLink) {
const config = this.runnerStat && this.runnerStat.instanceOptions[sessionId]
const isSauceJob = (
(config && config.hostname && config.hostname.includes('saucelabs')) ||
capabilities['sauce:options']
)
if (isSauceJob && config && config.user && config.key && sessionId) {
const multiremoteNote = isMultiremote ? ` ${instanceName}` : ''
const note = 'Check out%s job at %s'
if ('testobject_test_report_url' in capabilities){
return [format(note, multiremoteNote, capabilities.testobject_test_report_url)]
}
const isUSEast4 = ['us-east-4'].includes(config?.region || '') || (config.hostname?.includes('us-east-4'))
const isEUCentral = ['eu', 'eu-central-1'].includes(config?.region || '') || (config.hostname?.includes('eu-central'))
const dc = isUSEast4 ? '.us-east-4' : isEUCentral ? '.eu-central-1' : ''
const sauceLabsSharableLinks = this._sauceLabsSharableLinks
? sauceAuthenticationToken( config.user, config.key, sessionId )
: ''
const sauceUrl = `https://app${dc}.saucelabs.com/tests/${sessionId}${sauceLabsSharableLinks}`
return [format(note, multiremoteNote, sauceUrl)]
}
return []
}
* Get the header display for the report
* @param {Object} runner Runner data
* @return {Array} Header data
*/
getHeaderDisplay (runner: RunnerStats) {
const combo = this.getEnviromentCombo(runner.capabilities, undefined, runner.isMultiremote).trim()
const output = [`Running: ${combo}`]
* print session ID if not multiremote
*/
if (runner.capabilities.sessionId) {
output.push(`Session ID: ${runner.capabilities.sessionId}`)
}
return output
}
* returns everything worth reporting from a suite
* @param {Object} suite test suite containing tests and hooks
* @return {Object[]} list of events to report
*/
getEventsToReport (suite: SuiteStats) {
return [
* Generate a report that shows all tests except those that failed but passed on retry, and only display failed hooks.
*/
...suite.hooksAndTests.reduce((accumulator, currentItem) => {
if (currentItem instanceof TestStats) {
const existingTestIndex = accumulator.findIndex((test) => test instanceof TestStats && test.fullTitle === currentItem.fullTitle)
if (existingTestIndex === -1) {
accumulator.push(currentItem)
} else {
const existingTest = accumulator[existingTestIndex] as TestStats
if (currentItem.retries !== undefined && existingTest.retries !== undefined) {
if (currentItem.retries > existingTest.retries) {
accumulator.splice(existingTestIndex, 1, currentItem)
} else {
accumulator.push(currentItem)
}
}
}
} else {
accumulator.push(currentItem)
}
return accumulator
}, [] as (HookStats | TestStats)[]).filter((item) => Object.keys(item).length > 0).filter((item) => {
return item.type === 'test' || Boolean(item.error)
})
]
}
* Get the results from the tests
* @param {Array} suites Runner suites
* @return {Array} Display output list
*/
getResultDisplay (prefaceString?: string) {
const output = []
const preface = this._showPreface ? prefaceString : ''
const suites = this.getOrderedSuites()
const specFileReferences: string[] = []
for (const suite of suites) {
if (suite.tests.length === 0 && suite.suites.length === 0 && suite.hooks.length === 0) {
continue
}
const suiteIndent = this.indent(suite.uid)
if (suite.file && !specFileReferences.includes(suite.file)) {
output.push(`${suiteIndent}» ${suite.file.replace(process.cwd(), '').slice(1)}`)
specFileReferences.push(suite.file)
}
let retryAnnotation = ''
if (suite.retries > 0) {
retryAnnotation = this._chalk.yellow(` (${suite.retries}x retries)`)
}
output.push(`${suiteIndent}${suite.title}${retryAnnotation}`)
if (suite.description) {
output.push(...suite.description.trim().split('\n')
.map((l) => `${suiteIndent}${this.setMessageColor(l.trim())}`))
output.push('')
}
if (suite.rule) {
output.push(...suite.rule.trim().split('\n')
.map((l) => `${suiteIndent}${this.setMessageColor(l.trim())}`))
}
const eventsToReport = this.getEventsToReport(suite)
for (const test of eventsToReport) {
const testRetryAnnotation = (test instanceof TestStats && test.retries && test.retries > 0)
? this._chalk.yellow(`(${test.retries}x retries)`)
: ''
const testTitle = `${test.title} ${testRetryAnnotation}`
const state = test.state as State
const testIndent = `${DEFAULT_INDENT}${suiteIndent}`
output.push(`${testIndent}${this.setMessageColor(this.getSymbol(state), state)} ${testTitle.trim()}`)
const arg = (test as TestStats).argument
if (typeof(arg) === 'string'){
const docstringIndent = ' '
const docstringMark = `${testIndent}${docstringIndent}"""`
const docstring = String(arg)
const formattedDocstringLines = docstring.split('\n').filter(line => line)
.map((line: string) => `${testIndent}${docstringIndent}${line}`)
output.push(...[docstringMark, ...formattedDocstringLines, docstringMark])
} else {
const dataTable = arg as Argument
if (dataTable && dataTable.rows && dataTable.rows.length) {
const data = buildTableData(dataTable.rows)
const rawTable = printTable(data)
const table = getFormattedRows(rawTable, testIndent)
output.push(...table)
}
}
const pendingItem = this._pendingReasons.shift()
if (pendingItem) {
output.push('')
output.push(testIndent.repeat(2) + '.........Pending Reasons.........')
output.push(testIndent.repeat(3) + pendingItem?.replace(/\n/g, '\n'.concat(preface + ' ', testIndent.repeat(3))))
}
const logItem = this._consoleLogs.shift()
if (logItem) {
output.push('')
output.push(testIndent.repeat(2) + '.........Console Logs.........')
output.push(testIndent.repeat(3) + logItem?.replace(/\n/g, '\n'.concat(preface + ' ', testIndent.repeat(3))))
}
}
if (eventsToReport.length) {
output.push('')
}
}
return output
}
* Get the display for passing, failing and skipped
* @param {string} duration Duration string
* @return {Array} Count display
*/
getCountDisplay (duration: string) {
const output: string[] = []
if (this._stateCounts.passed > 0) {
const text = `${this._stateCounts.passed} passing ${duration}`
output.push(this.setMessageColor(text, State.PASSED))
duration = ''
}
if (this._stateCounts.failed > 0) {
const text = `${this._stateCounts.failed} failing ${duration}`.trim()
output.push(this.setMessageColor(text, State.FAILED))
duration = ''
}
if (this._stateCounts.skipped > 0) {
const text = `${this._stateCounts.skipped} skipped ${duration}`.trim()
output.push(this.setMessageColor(text, State.SKIPPED))
}
if (this._stateCounts.pending > 0) {
const text = `${this._stateCounts.pending} pending ${duration}`.trim()
output.push(this.setMessageColor(text, State.PENDING))
duration = ''
}
if (this._stateCounts.retried > 0) {
const text = `${this._stateCounts.retried} retried ${duration}`.trim()
output.push(this.setMessageColor(text, State.RETRIED))
}
return output
}
* Get display for failed tests, e.g. stack trace
* @return {Array} Stack trace output
*/
getFailureDisplay () {
let failureLength = 0
const output = []
const suites = this.getOrderedSuites()
for (const suite of suites) {
const suiteTitle = suite.title
const eventsToReport = this.getEventsToReport(suite)
for (const test of eventsToReport) {
if (test.state !== State.FAILED) {
continue
}
const testTitle = test.title
const errors: Error[] = test.errors || (test.error ? [test.error] : [])
output.push(
'',
`${++failureLength}) ${suiteTitle} ${testTitle}`,
)
for (const error of errors) {
if (!error?.stack?.includes('new AssertionError')) {
output.push(this.setMessageColor(error.message, State.FAILED))
} else {
output.push(...error.message.split('\n'))
}
if (error.stack) {
output.push(...error.stack.split(/\n/g).map(value => this.setMessageColor(value)))
}
}
}
}
return output
}
* Get suites in the order they were called
* @return {Array} Ordered suites
*/
getOrderedSuites () {
if (this._orderedSuites.length) {
return this._orderedSuites
}
this._orderedSuites = []
for (const uid of this._suiteUids) {
for (const [suiteUid, suite] of Object.entries(this.suites)) {
if (suiteUid !== uid) {
continue
}
this._orderedSuites.push(suite)
}
}
* ensure we include root suite hook errors
*/
const rootSuite = this.currentSuites[0]
if (rootSuite) {
const baseRootSuite = {
...rootSuite,
type: 'suite',
title: '(root)',
fullTitle: '(root)',
suites: []
}
const beforeAllHooks = rootSuite.hooks.filter((hook) => hook.state && hook.title.startsWith('"before') && hook.title.endsWith('"{root}"'))
const afterAllHooks = rootSuite.hooks.filter((hook) => hook.state && hook.title.startsWith('"after') && hook.title.endsWith('"{root}"'))
this._orderedSuites.unshift(Object.assign({} as SuiteStats, baseRootSuite, {
hooks: beforeAllHooks,
hooksAndTests: beforeAllHooks
}))
this._orderedSuites.push(Object.assign({} as SuiteStats, baseRootSuite, {
hooks: afterAllHooks,
hooksAndTests: afterAllHooks
}))
}
return this._orderedSuites
}
* Indent a suite based on where how it's nested
* @param {string} uid Unique suite key
* @return {String} Spaces for indentation
*/
indent (uid: string) {
const indents = this._suiteIndents[uid]
return indents === 0 ? '' : Array(indents).join(' ')
}
* Get a symbol based on state
* @param {string} state State of a test
* @return {String} Symbol to display
*/
getSymbol (state?: keyof Symbols) {
return (state && this._symbols[state]) || '?'
}
* Get a color based on a given state
* @param {string} state Test state
* @return {String} State color
*/
getColor (state?: string): ChalkColors {
let color = ChalkColors.GRAY
switch (state) {
case State.PASSED:
color = ChalkColors.GREEN
break
case State.PENDING:
case State.SKIPPED:
color = ChalkColors.CYAN
break
case State.FAILED:
color = ChalkColors.RED
break
case State.RETRIED:
color = ChalkColors.YELLOW
break
}
return color
}
* Get information about the enviroment
* @param capability
* @param {Boolean} verbose
* @param isMultiremote
* @return {String} Enviroment string
*/
getEnviromentCombo (capability: Capabilities.ResolvedTestrunnerCapabilities, verbose = true, isMultiremote = false): string {
if (isMultiremote) {
const browserNames = Object.values(capability).map((c) => c.browserName)
const browserName = browserNames.length > 1
? `${browserNames.slice(0, -1).join(', ')} and ${browserNames.pop()}`
: browserNames.pop()
return `MultiremoteBrowser on ${browserName}`
}
const caps = 'alwaysMatch' in capability ? capability.alwaysMatch : capability
const device = caps['appium:deviceName']
const browser = getBrowserName(caps)
* fallback to different capability types:
* browserVersion: W3C format
* version: JSONWP format
* platformVersion: mobile format
* browser_version: invalid BS capability
*/
const version = caps.browserVersion || caps.version || caps['appium:platformVersion'] || caps.browser_version
* fallback to different capability types:
* platformName: W3C format
* platform: JSONWP format
* os, os_version: invalid BS capability
*/
const platform = caps.platformName || caps['appium:platformName'] || caps.platform || (caps.os ? caps.os + (caps.os_version ? ` ${caps.os_version}` : '') : '(unknown)')
if (device) {
const program = getBrowserName(caps)
const executing = program ? `executing ${program}` : ''
if (!verbose) {
return `${device} ${platform} ${version}`
}
return `${device} on ${platform} ${version} ${executing}`.trim()
}
if (!verbose) {
return (browser + (version ? ` ${version} ` : ' ') + (platform)).trim()
}
return browser + (version ? ` (v${version})` : '') + (` on ${platform}`)
}
}