import path from 'node:path'
import type { SuiteStats, TestStats, RunnerStats, HookStats } from '@wdio/reporter'
import WDIOReporter from '@wdio/reporter'
import type { Options } from '@wdio/types'
import * as url from 'node:url'
import { v4 as uuidv4 } from 'uuid'
import type { CurrentRunInfo, StdLog } from './types.js'
import type { BrowserstackConfig, TestData, TestMeta } from './types.js'
import {
getCloudProvider,
o11yClassErrorHandler,
getGitMetaData,
removeAnsiColors,
getHookType,
getPlatformVersion,
isObjectEmpty,
generateHashCodeFromFields
} from './util.js'
import { BStackLogger } from './bstackLogger.js'
import type { Capabilities } from '@wdio/types'
import Listener from './testOps/listener.js'
class _TestReporter extends WDIOReporter {
private _capabilities: WebdriverIO.Capabilities = {}
private _config?: BrowserstackConfig & Options.Testrunner
private _observability = true
private _sessionId?: string
private _suiteName?: string
private _suites: SuiteStats[] = []
private static _tests: Record<string, TestMeta> = {}
private _gitConfigPath?: string
private _gitConfigured: boolean = false
private _currentHook: CurrentRunInfo = {}
public static currentTest: CurrentRunInfo = {}
private _userCaps?: Capabilities.ResolvedTestrunnerCapabilities = {}
private listener = Listener.getInstance()
public static hashCodeToHandleTestSkip: Record<string, string> = {}
async onRunnerStart (runnerStats: RunnerStats) {
this._capabilities = runnerStats.capabilities as WebdriverIO.Capabilities
this._userCaps = this.getUserCaps(runnerStats)
this._config = runnerStats.config as BrowserstackConfig & Options.Testrunner
this._sessionId = runnerStats.sessionId
if (typeof this._config.testObservability !== 'undefined') {
this._observability = this._config.testObservability
}
await this.configureGit()
this.registerListeners()
}
private getUserCaps(runnerStats: RunnerStats) {
return runnerStats.capabilities
}
registerListeners () {
if (this._config?.framework !== 'jasmine') {
return
}
process.removeAllListeners(`bs:addLog:${process.pid}`)
process.on(`bs:addLog:${process.pid}`, this.appendTestItemLog.bind(this))
}
public async appendTestItemLog(stdLog: StdLog) {
if (this._currentHook.uuid && !this._currentHook.finished) {
stdLog.hook_run_uuid = this._currentHook.uuid
} else if (_TestReporter.currentTest.uuid) {
stdLog.test_run_uuid = _TestReporter.currentTest.uuid
}
if (stdLog.hook_run_uuid || stdLog.test_run_uuid) {
this.listener.logCreated([stdLog])
}
}
setCurrentHook(hookDetails: CurrentRunInfo) {
if (hookDetails.finished) {
if (this._currentHook.uuid === hookDetails.uuid) {
this._currentHook.finished = true
}
return
}
this._currentHook = {
uuid: hookDetails.uuid,
finished: false
}
}
async configureGit() {
if (this._gitConfigured) {
return
}
const gitMeta = await getGitMetaData()
if (gitMeta) {
this._gitConfigPath = gitMeta.root
}
this._gitConfigured = true
}
static getTests() {
return _TestReporter._tests
}
onSuiteStart (suiteStats: SuiteStats) {
let filename = suiteStats.file
if (this._config?.framework === 'jasmine') {
try {
if (suiteStats.file.startsWith('file://')) {
filename = url.fileURLToPath(suiteStats.file)
}
if (filename === 'unknown spec file') {
filename = this._suiteName || suiteStats.file
}
} catch {
BStackLogger.debug('Error in decoding file name of suite')
}
}
this._suiteName = filename
this._suites.push(suiteStats)
}
onSuiteEnd() {
this._suites.pop()
}
needToSendData(testType?: string, event?: string) {
if (!this._observability) {return false}
switch (this._config?.framework) {
case 'mocha':
return event === 'skip'
case 'cucumber':
return false
case 'jasmine':
return event !== 'skip'
default:
return false
}
}
async onTestEnd(testStats: TestStats) {
if (!this.needToSendData('test', 'end')) {
return
}
if (testStats.fullTitle === '<unknown test>') {
return
}
testStats.end ||= new Date()
this.listener.testFinished(await this.getRunData(testStats, 'TestRunFinished'))
}
async onTestStart(testStats: TestStats) {
if (!this.needToSendData('test', 'start')) {
return
}
if (testStats.fullTitle === '<unknown test>') {
return
}
const uuid = uuidv4()
_TestReporter.currentTest.uuid = uuid
_TestReporter._tests[testStats.fullTitle] = {
uuid: uuid,
}
this.listener.testStarted(await this.getRunData(testStats, 'TestRunStarted'))
}
async onHookStart(hookStats: HookStats) {
if (!this.needToSendData('hook', 'start')) {
return
}
const identifier = this.getHookIdentifier(hookStats)
const hookId = uuidv4()
this.setCurrentHook({ uuid: hookId })
_TestReporter._tests[identifier] = {
uuid: hookId,
startedAt: (new Date()).toISOString()
}
this.listener.hookStarted(await this.getRunData(hookStats, 'HookRunStarted'))
}
async onHookEnd(hookStats: HookStats) {
if (!this.needToSendData('hook', 'end')) {
return
}
const identifier = this.getHookIdentifier(hookStats)
if (_TestReporter._tests[identifier]) {
_TestReporter._tests[identifier].finishedAt = (new Date()).toISOString()
} else {
_TestReporter._tests[identifier] = {
finishedAt: (new Date()).toISOString()
}
}
this.setCurrentHook({ uuid: _TestReporter._tests[identifier].uuid, finished: true })
if (!hookStats.state && !hookStats.error) {
hookStats.state = 'passed'
}
this.listener.hookFinished(await this.getRunData(hookStats, 'HookRunFinished'))
}
getHookIdentifier(hookStats: HookStats) {
return `${hookStats.title} for ${this._suites.at(-1)?.title}`
}
async onTestSkip (testStats: TestStats) {
if (!this.needToSendData('test', 'skip')) {
return
}
testStats.start ||= new Date()
testStats.end ||= new Date()
const testData = await this.getRunData(testStats, 'TestRunSkipped')
const testFinishHashCode = generateHashCodeFromFields(
[
testData.integrations?.browserstack?.browser ?? '',
testData.integrations?.browserstack?.browser_version ?? '',
testData.integrations?.browserstack?.platform ?? '',
testData.integrations?.browserstack?.session_id ?? '',
testData.integrations?.capabilities ?? {},
testData.file_name ?? '',
testData.scopes ?? [],
testData.name ?? ''
]
)
if (_TestReporter.hashCodeToHandleTestSkip !== null && !isObjectEmpty(_TestReporter.hashCodeToHandleTestSkip) && testFinishHashCode in _TestReporter.hashCodeToHandleTestSkip) {
if (_TestReporter.hashCodeToHandleTestSkip[testFinishHashCode] !== '') {
testData.uuid = _TestReporter.hashCodeToHandleTestSkip[testFinishHashCode]
}
}
this.listener.testFinished(testData)
}
async getRunData(testStats: TestStats | HookStats, eventType: string) {
const framework = this._config?.framework
const scopes = this._suites.map(s => s.title)
const identifier = testStats.type === 'test' ? (testStats as TestStats).fullTitle : this.getHookIdentifier(testStats as HookStats)
const testMetaData: TestMeta = _TestReporter._tests[identifier]
const scope = testStats.type === 'test' ? (testStats as TestStats).fullTitle : `${this._suites[0].title} - ${testStats.title}`
const suiteFileName = this._suiteName || (this.specs?.length > 0 ? this.specs[this.specs.length - 1]?.replace('file:', '') : undefined)
if (eventType === 'TestRunStarted') {
_TestReporter.currentTest.name = testStats.title
}
await this.configureGit()
const testData: TestData = {
uuid: testMetaData ? testMetaData.uuid : uuidv4(),
type: testStats.type,
name: testStats.title,
body: {
lang: 'webdriverio',
code: null
},
scope: scope,
scopes: scopes,
identifier: identifier,
file_name: suiteFileName ? path.relative(process.cwd(), suiteFileName) : undefined,
location: suiteFileName ? path.relative(process.cwd(), suiteFileName) : undefined,
vc_filepath: (this._gitConfigPath && suiteFileName) ? path.relative(this._gitConfigPath, suiteFileName) : undefined,
started_at: testStats.start && testStats.start.toISOString(),
finished_at: testStats.end && testStats.end.toISOString(),
framework: framework,
duration_in_ms: testStats._duration,
result: testStats.state,
}
if (testStats.type === 'test') {
testData.retries = { limit: (testStats as TestStats).retries || 0, attempts: (testStats as TestStats).retries || 0 }
}
if (eventType.startsWith('TestRun') || eventType === 'HookRunStarted') {
const cloudProvider = getCloudProvider({ options: { hostname: this._config?.hostname } } as WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser)
testData.integrations = {}
testData.integrations[cloudProvider] = {
capabilities: this._capabilities,
session_id: this._sessionId,
browser: this._capabilities?.browserName,
browser_version: this._capabilities?.browserVersion,
platform: this._capabilities?.platformName,
platform_version: getPlatformVersion(this._capabilities, this._userCaps as WebdriverIO.Capabilities)
}
}
if (eventType === 'TestRunFinished' || eventType === 'HookRunFinished') {
const { error } = testStats
const failed = testStats.state === 'failed'
if (failed) {
testData.result = (error && error.message && error.message.includes('sync skip; aborting execution')) ? 'ignore' : 'failed'
if (error && testData.result !== 'skipped') {
testData.failure = [{ backtrace: [removeAnsiColors(error.message), removeAnsiColors(error.stack || '')] }]
testData.failure_reason = removeAnsiColors(error.message)
testData.failure_type = error.message === null ? null : error.message.toString().match(/AssertionError/) ? 'AssertionError' : 'UnhandledError'
}
}
}
if (eventType === 'TestRunSkipped') {
eventType = 'TestRunFinished'
}
if (eventType.match(/HookRun/)) {
testData.hook_type = testData.name?.toLowerCase() ? getHookType(testData.name.toLowerCase()) : 'undefined'
}
return testData
}
}
const TestReporter: typeof _TestReporter = o11yClassErrorHandler(_TestReporter)
type TestReporter = _TestReporter
export default TestReporter