From 538bfcfd21ca37568dfb4d7d0567075cfa49a9b2 Mon Sep 17 00:00:00 2001 From: Bill Glesias Date: Fri, 22 May 2026 14:53:25 -0400 Subject: [PATCH] refactor(server): convert reporter to TypeScript with type enhancements (#33879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(server): convert reporter to TypeScript Basic TypeScript conversion of the server reporter module and its unit tests. Co-authored-by: Cursor * refactor(server): enhance types for reporter TypeScript conversion Adds more precise type annotations on top of the initial TS conversion, particularly around mocha runnable object shapes where runtime checks default to undefined — improving debuggability if bugs surface in the reporter. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Cursor Co-authored-by: Claude Sonnet 4.6 --- .../{reporter_spec.js => reporter_spec.ts.js} | 0 packages/server/lib/modes/run.ts | 2 +- packages/server/lib/project-base.ts | 2 +- .../server/lib/{reporter.js => reporter.ts} | 275 +++++++++++------- packages/server/lib/types/reporter.ts | 208 +++++++++++-- .../lib/util/graceful_crash_handling.ts | 29 +- .../server/test/integration/cypress_spec.js | 2 +- .../{reporter_spec.js => reporter_spec.ts} | 6 +- ...reporters_spec.js => reporters_spec.ts.js} | 2 +- .../{reporters_spec.js => reporters_spec.ts} | 0 10 files changed, 376 insertions(+), 150 deletions(-) rename packages/server/__snapshots__/{reporter_spec.js => reporter_spec.ts.js} (100%) rename packages/server/lib/{reporter.js => reporter.ts} (68%) rename packages/server/test/unit/{reporter_spec.js => reporter_spec.ts} (98%) rename system-tests/__snapshots__/{reporters_spec.js => reporters_spec.ts.js} (99%) rename system-tests/test/{reporters_spec.js => reporters_spec.ts} (100%) diff --git a/packages/server/__snapshots__/reporter_spec.js b/packages/server/__snapshots__/reporter_spec.ts.js similarity index 100% rename from packages/server/__snapshots__/reporter_spec.js rename to packages/server/__snapshots__/reporter_spec.ts.js diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 8de257f024..9912b7e457 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -9,7 +9,7 @@ import assert from 'assert' import recordMode from './record' import * as errors from '../errors' -import Reporter from '../reporter' +import { Reporter } from '../reporter' import browserUtils from '../browsers' import { openProject } from '../open_project' import * as videoCapture from '../video_capture' diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 537be53d6b..b407c722e3 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -10,7 +10,7 @@ import * as config from './config' import * as errors from './errors' import preprocessor from './plugins/preprocessor' import runEvents from './plugins/run_events' -import Reporter from './reporter' +import { Reporter } from './reporter' import * as savedState from './saved_state' import { SocketCt } from './socket-ct' import { SocketE2E } from './socket-e2e' diff --git a/packages/server/lib/reporter.js b/packages/server/lib/reporter.ts similarity index 68% rename from packages/server/lib/reporter.js rename to packages/server/lib/reporter.ts index 940d2e963c..2067c9debc 100644 --- a/packages/server/lib/reporter.js +++ b/packages/server/lib/reporter.ts @@ -1,32 +1,55 @@ -const _ = require('lodash') -const path = require('path') -const { stackUtils } = require('@packages/errors') +import _ from 'lodash' +import path from 'path' +import { stackUtils } from '@packages/errors' // mocha-* is used to allow us to have later versions of mocha specified in devDependencies // and prevents accidentally upgrading this one // TODO: look into upgrading this to version in driver -const Mocha = require('mocha-7.2.0') -const mochaReporters = require('mocha-7.2.0/lib/reporters') -const mochaCreateStatsCollector = require('mocha-7.2.0/lib/stats-collector') +import Mocha from 'mocha-7.2.0' +import mochaReporters from 'mocha-7.2.0/lib/reporters' +import mochaCreateStatsCollector from 'mocha-7.2.0/lib/stats-collector' +import Debug from 'debug' +import { overrideRequire } from './override_require' +import type { + CypressTestStatusInfo, + InternalRunnable, + ReporterEventHandlers, + ReporterEventName, + ReporterHook, + ReporterResults, + ReporterStats, + ReporterTest, + ReporterTestAttempt, + ReporterTestError, + RetriesConfig, + RunnableAttemptPayload, + RunnablePayload, + RunnableState, +} from './types/reporter' + +const debug = Debug('cypress:server:reporter') const mochaColor = mochaReporters.Base.color const mochaSymbols = mochaReporters.Base.symbols -const debug = require('debug')('cypress:server:reporter') -const { overrideRequire } = require('./override_require') - // override calls to `require('mocha*')` when to always resolve with a mocha we control // otherwise mocha will be resolved from project's node_modules and might not work with our code const customReporterMochaPath = path.dirname(require.resolve('mocha-7.2.0')) -const buildAttemptMessage = (currentRetry, totalRetries) => { +interface IconStatus { + overallStatusSymbol: string | undefined + overallStatusSymbolColor: string | undefined + overallStatusColor: string +} + +const buildAttemptMessage = (currentRetry: number, totalRetries: number): string => { return `(Attempt ${currentRetry} of ${totalRetries})` } // Used for experimentalRetries, where we want to display the passed/failed // status of an attempt in the console -const getIconStatus = (status) => { - let overallStatusSymbol - let overallStatusSymbolColor - let overallStatusColor +const getIconStatus = (status: RunnableState | undefined): IconStatus => { + let overallStatusSymbol: string | undefined + let overallStatusSymbolColor: string | undefined + let overallStatusColor: string switch (status) { case 'passed': @@ -75,7 +98,7 @@ overrideRequire((depPath, _load) => { // Mocha.Runnable.prototype.titlePath = -> // @parent.titlePath().concat([@title]) -const getTitlePath = function (runnable, titles = []) { +const getTitlePath = function (runnable: InternalRunnable, titles: string[] = []): string[] { // `originalTitle` is a Mocha Hook concept used to associated the // hook to the test that executed it if (runnable.originalTitle) { @@ -97,7 +120,7 @@ const getTitlePath = function (runnable, titles = []) { return titles } -const createSuite = function (obj, parent) { +const createSuite = function (obj: RunnablePayload, parent?: InternalRunnable) { const suite = new Mocha.Suite(obj.title, {}) if (parent) { @@ -107,10 +130,10 @@ const createSuite = function (obj, parent) { suite.file = obj.file suite.root = !!obj.root - return suite + return suite as unknown as InternalRunnable } -const createRunnable = function (obj, parent) { +const createRunnable = function (obj: RunnablePayload, parent?: InternalRunnable) { let fn const { body } = obj @@ -137,10 +160,10 @@ const createRunnable = function (obj, parent) { } if (parent) { - runnable.parent = parent + runnable.parent = parent as unknown as Mocha.Suite } - return runnable + return runnable as unknown as InternalRunnable } const mochaProps = { @@ -148,17 +171,17 @@ const mochaProps = { 'retries': '_retries', } -const toMochaProps = (testProps) => { +const toMochaProps = (testProps: Record) => { return _.each(mochaProps, (val, key) => { if (testProps.hasOwnProperty(key)) { testProps[val] = testProps[key] - return delete testProps[key] + delete testProps[key] } }) } -const toAttemptProps = (runnable) => { +const toAttemptProps = (runnable: InternalRunnable): RunnableAttemptPayload => { return _.pick(runnable, [ 'err', 'state', @@ -169,14 +192,14 @@ const toAttemptProps = (runnable) => { ]) } -const mergeRunnable = (eventName) => { - return (function (testProps, runnables) { - toMochaProps(testProps) +const mergeRunnable = (eventName: ReporterEventName) => { + return (function (testProps: RunnablePayload, runnables: Record) { + toMochaProps(testProps as Record) - const runnable = runnables[testProps.id] + const runnable = runnables[testProps.id!] if (eventName === 'test:before:run') { - if (testProps._currentRetry > runnable._currentRetry) { + if ((testProps._currentRetry ?? 0) > (runnable._currentRetry ?? 0)) { debug('test retried:', testProps.title) const prevAttempts = runnable.prevAttempts || [] @@ -190,14 +213,14 @@ const mergeRunnable = (eventName) => { } } - return [_.extend(runnable, testProps)] + return [_.extend(runnable, testProps) as InternalRunnable] }) } -const safelyMergeRunnable = function (hookProps, runnables) { +const safelyMergeRunnable = function (hookProps: RunnablePayload, runnables: Record) { const { hookId, title, hookName, body, type } = hookProps - if (!runnables[hookId]) { + if (hookId && !runnables[hookId]) { runnables[hookId] = { hookId, type, @@ -207,13 +230,13 @@ const safelyMergeRunnable = function (hookProps, runnables) { } } - return [_.extend({}, runnables[hookProps.id], hookProps)] + return [_.extend({}, runnables[hookProps.id!], hookProps) as InternalRunnable] } -const mergeErr = function (runnable, runnables, stats) { +const mergeErr = function (runnable: RunnablePayload, runnables: Record, _stats: ReporterStats) { // this will always be a test because // we reset hook id's to match tests - let test = runnables[runnable.id] + let test = runnables[runnable.id!] test.err = runnable.err test.state = 'failed' @@ -230,19 +253,17 @@ const mergeErr = function (runnable, runnables, stats) { // in the terminal, as well as updating the test state as it may have changed in the client-side runner. test = _.extend({}, test, { title: runnable.title, _cypressTestStatusInfo: runnable._cypressTestStatusInfo, state: runnable.state }) - return [test, test.err] + return [test, test.err] as [InternalRunnable, ReporterTestError | string | undefined] } -const setDate = function (obj, runnables, stats) { - let e; let s - - s = obj.start +const setDate = function (obj: RunnablePayload, _runnables: Record, stats: ReporterStats) { + const s = obj.start if (s) { stats.wallClockStartedAt = new Date(s) } - e = obj.end + const e = obj.end if (e) { stats.wallClockEndedAt = new Date(e) @@ -251,13 +272,13 @@ const setDate = function (obj, runnables, stats) { return [] } -const orNull = function (prop) { +const orNull = function (prop: T | null | undefined): T | null { if (prop == null) return null return prop } -const events = { +const events: ReporterEventHandlers = { 'start': setDate, 'end': setDate, 'suite': mergeRunnable('suite'), @@ -274,8 +295,18 @@ const events = { 'test:before:run': mergeRunnable('test:before:run'), // our own custom event } -class Reporter { - constructor (reporterName = 'spec', reporterOptions = {}, projectRoot) { +export class Reporter { + reporterName!: string + projectRoot!: string | undefined + reporterOptions!: Record + stats!: ReporterStats + retriesConfig!: RetriesConfig + runnables!: Record + mocha!: Mocha + runner!: Mocha.Runner + reporter!: Mocha.reporters.Base & { done?: (failures: number, resolve: () => void) => void } + + constructor (reporterName = 'spec', reporterOptions: Record = {}, projectRoot?: string) { if (!(this instanceof Reporter)) { return new Reporter(reporterName) } @@ -283,24 +314,31 @@ class Reporter { this.reporterName = reporterName this.projectRoot = projectRoot this.reporterOptions = reporterOptions + this.retriesConfig = {} this.normalizeTest = this.normalizeTest.bind(this) } - setRunnables (rootRunnable, cypressConfig) { - if (!rootRunnable) { + setRunnables (rootRunnable: RunnablePayload | unknown | undefined, cypressConfig?: unknown) { + if (Array.isArray(rootRunnable)) { + rootRunnable = rootRunnable[0] as RunnablePayload | undefined + } + + if (!rootRunnable || typeof rootRunnable !== 'object') { rootRunnable = { title: '' } } + const config = cypressConfig as { retries?: RetriesConfig } | undefined + // manage stats ourselves - this.stats = { suites: 0, tests: 0, passes: 0, pending: 0, skipped: 0, failures: 0 } - this.retriesConfig = cypressConfig ? cypressConfig.retries : {} + this.stats = { suites: 0, tests: 0, passes: 0, pending: 0, skipped: 0, failures: 0, wallClockDuration: 0 } + this.retriesConfig = config?.retries ?? {} this.runnables = {} - rootRunnable = this._createRunnable(rootRunnable, 'suite') + rootRunnable = this._createRunnable(rootRunnable as RunnablePayload, 'suite') const reporter = Reporter.loadReporter(this.reporterName, this.projectRoot) this.mocha = new Mocha({ reporter }) - this.mocha.suite = rootRunnable - this.runner = new Mocha.Runner(rootRunnable) + this.mocha.suite = rootRunnable as unknown as Mocha.Suite + this.runner = new Mocha.Runner(rootRunnable as unknown as Mocha.Suite) mochaCreateStatsCollector(this.runner) this.reporter = new this.mocha._reporter(this.runner, { @@ -323,9 +361,9 @@ class Reporter { // Override the default reporter to always show test timing even for fast tests // and display slow ones in yellow rather than red - this.runner._events.pass[2] = function (test) { + this.runner._events.pass[2] = function (test: InternalRunnable) { // can possibly be undefined if the test fails before being run, such as in before/beforeEach hooks - const cypressTestMetaData = test._cypressTestStatusInfo + const cypressTestMetaData: CypressTestStatusInfo | undefined = test._cypressTestStatusInfo const durationColor = test.speed === 'slow' ? 'medium' : 'fast' @@ -345,19 +383,20 @@ class Reporter { } else { // If there have been no retries and experimental retries is configured, // DON'T decorate the last test in the console as an attempt. - if (cypressTestMetaData?.attempts > 1) { + if ((cypressTestMetaData?.attempts ?? 0) > 1) { const lastTestStatus = getIconStatus(test.state) + const attempts = cypressTestMetaData!.attempts! fmt = Array(indents).join(' ') + - mochaColor(lastTestStatus.overallStatusColor, ` ${ lastTestStatus.overallStatusSymbol}${buildAttemptMessage(cypressTestMetaData.attempts, test.retries + 1)}`) + mochaColor(lastTestStatus.overallStatusColor, ` ${ lastTestStatus.overallStatusSymbol}${buildAttemptMessage(attempts, (test.retries ?? 0) + 1)}`) // or Log: `✓(Attempt 3 of 3) test title` when the overall outerStatus of a test has passed // eslint-disable-next-line no-console console.log(fmt, test.title) } - const finalTestStatus = getIconStatus(cypressTestMetaData.outerStatus || test.state) + const finalTestStatus = getIconStatus(cypressTestMetaData?.outerStatus || test.state) const finalMessaging = Array(indents).join(' ') + @@ -375,9 +414,9 @@ class Reporter { const originalFailPrint = this.runner._events.fail[2] - this.runner._events.fail[2] = function (test) { + this.runner._events.fail[2] = function (test: InternalRunnable) { // can possibly be undefined if the test fails before being run, such as in before/beforeEach hooks - const cypressTestMetaData = test._cypressTestStatusInfo + const cypressTestMetaData: CypressTestStatusInfo | undefined = test._cypressTestStatusInfo // print the default if the experiment is not configured if (!retriesConfig?.experimentalStrategy) { @@ -388,12 +427,13 @@ class Reporter { // If there have been no retries and experimental retries is configured, // DON'T decorate the last test in the console as an attempt. - if (cypressTestMetaData?.attempts > 1) { + if ((cypressTestMetaData?.attempts ?? 0) > 1) { const lastTestStatus = getIconStatus(test.state) + const attempts = cypressTestMetaData!.attempts! const fmt = Array(indents).join(' ') + - mochaColor(lastTestStatus.overallStatusColor, ` ${ lastTestStatus.overallStatusSymbol}${buildAttemptMessage(cypressTestMetaData.attempts, test.retries + 1)}`) + mochaColor(lastTestStatus.overallStatusColor, ` ${ lastTestStatus.overallStatusSymbol}${buildAttemptMessage(attempts, (test.retries ?? 0) + 1)}`) // Log: `✖(Attempt 3 of 3) test title (300ms)` when a test fails and none of the retries have passed // eslint-disable-next-line no-console @@ -417,7 +457,7 @@ class Reporter { } } - _createRunnable (runnableProps, type, parent) { + _createRunnable (runnableProps: RunnablePayload, type: 'suite' | 'test', parent?: InternalRunnable): InternalRunnable { const runnable = (() => { switch (type) { case 'suite': @@ -442,54 +482,56 @@ class Reporter { runnable.id = runnableProps.id - this.runnables[runnableProps.id] = runnable + this.runnables[runnableProps.id!] = runnable return runnable } - emit (event, arg) { + emit (event: ReporterEventName | string, arg?: RunnablePayload) { // iI using GA retries, log the retry attempt as the status of the attempt is not used - if (event === 'retry' && this.reporterName === 'spec' && !this.retriesConfig?.experimentalStrategy) { + if (event === 'retry' && this.reporterName === 'spec' && !this.retriesConfig?.experimentalStrategy && arg) { this._logRetry(arg) } // If using experimental retries, log the attempt after the test attempt runs to accurately represent the attempt pass/fail status // We don't log the last attempt as this is handled by the main pass/fail handler defined above, and would ultimately log AFTER the test complete test status is reported // from the mocha:pass/mocha:fail event - if (event === 'test:after:run' && this.reporterName === 'spec' && this.retriesConfig?.experimentalStrategy && !arg.final) { + if (event === 'test:after:run' && this.reporterName === 'spec' && this.retriesConfig?.experimentalStrategy && arg && !arg.final) { this._logRetry(arg) } // ignore the event if we haven't accounted for it - if (!events[event]) return + if (!(event in events)) return - const args = this.parseArgs(event, arg) + const args = this.parseArgs(event as ReporterEventName, arg) - return this.runner && this.runner.emit.apply(this.runner, args) + return this.runner && this.runner.emit.apply(this.runner, args as Parameters) } - parseArgs (event, arg) { + parseArgs (event: ReporterEventName, arg?: RunnablePayload): [string, ...unknown[]] { const normalizeArgs = events[event] const args = _.isFunction(normalizeArgs) - ? normalizeArgs.call(this, arg, this.runnables, this.stats) + ? normalizeArgs.call(this, arg ?? { id: '' }, this.runnables, this.stats) : [arg] // the normalizeArgs function needs the id property, but we want to remove // and other private props before logging/emitting to mocha - args[0] = _.isPlainObject(args[0]) ? _.omit(args[0], ['id', 'hookId']) : args[0] + if (_.isPlainObject(args[0])) { + args[0] = _.omit(args[0] as object, ['id', 'hookId']) + } debug('got mocha event \'%s\' with args: %o', event, args) - return [event].concat(args) + return [event, ...args] as [string, ...unknown[]] } - _logRetry (test) { - const runnable = this.runnables[test.id] + _logRetry (test: RunnablePayload) { + const runnable = this.runnables[test.id!] // Merge the runnable with the updated test props to gain most recent status from the app runnable (in the case a passed test is retried). _.extend(runnable, test) - const padding = ' '.repeat(runnable.titlePath().length) + const padding = ' '.repeat(runnable.titlePath!().length) // Don't display a pass/fail symbol if we don't know the status. let mochaSymbolToDisplay = '' @@ -510,7 +552,7 @@ class Reporter { } } - const attemptMessage = mochaColor(mochaColorScheme, buildAttemptMessage(test.currentRetry + 1, test.retries + 1)) + const attemptMessage = mochaColor(mochaColorScheme, buildAttemptMessage((test.currentRetry ?? 0) + 1, (test.retries ?? 0) + 1)) // Log: `(Attempt 1 of 2) test title` when a test attempts without experimentalRetries configured. // OR @@ -521,7 +563,7 @@ class Reporter { return console.log(`${padding}${mochaSymbolToDisplay}${attemptMessage} ${test.title}`) } - normalizeHook (hook = {}) { + normalizeHook (hook: InternalRunnable = {}): ReporterHook { return { hookId: hook.hookId, hookName: hook.hookName, @@ -530,8 +572,8 @@ class Reporter { } } - normalizeTest (test = {}) { - let outerTest = _.clone(test) + normalizeTest (test: InternalRunnable = {}): ReporterTest { + const outerTest = _.clone(test) // In the case tests were skipped or another case where they haven't run, // test._cypressTestStatusInfo.outerStatus will be undefined. In this case, @@ -554,21 +596,34 @@ class Reporter { title: getTitlePath(outerTest), state: orNull(outerTest.state), body: orNull(outerTest.body), - displayError: orNull(outerTest.err && outerTest.err.stack), - attempts: _.map((outerTest.prevAttempts || []).concat([test]), (attempt) => { - const err = attempt.err && { - name: attempt.err.name, - message: attempt.err.message, - stack: attempt.err.stack && stackUtils.stackWithoutMessage(attempt.err.stack), - codeFrame: attempt.err.codeFrame, - } + displayError: orNull( + typeof outerTest.err === 'object' && outerTest.err?.stack + ? outerTest.err.stack + : null, + ), + attempts: _.map((outerTest.prevAttempts || []).concat([test]), (attempt): ReporterTestAttempt => { + const errSource = attempt.err + const err = errSource ? { + name: typeof errSource === 'object' ? errSource.name : undefined, + message: typeof errSource === 'object' ? errSource.message : undefined, + stack: typeof errSource === 'object' + ? errSource.stack + ? stackUtils.stackWithoutMessage(errSource.stack) + : errSource.stack + : undefined, + codeFrame: typeof errSource === 'object' ? errSource.codeFrame : undefined, + } : undefined return { state: orNull(attempt.state), error: orNull(err), timings: orNull(attempt.timings), failedFromHookId: orNull(attempt.failedFromHookId), - wallClockStartedAt: orNull(attempt.wallClockStartedAt && new Date(attempt.wallClockStartedAt)), + wallClockStartedAt: orNull( + attempt.wallClockStartedAt != null + ? new Date(attempt.wallClockStartedAt) + : null, + ), wallClockDuration: orNull(attempt.wallClockDuration), videoTimestamp: null, } @@ -578,14 +633,14 @@ class Reporter { return normalizedTest } - end () { + end (): Promise | ReporterResults { if (this.reporter.done) { const { failures, } = this.runner - return new Promise((resolve, reject) => { - return this.reporter.done(failures, resolve) + return new Promise((resolve) => { + return this.reporter.done!(failures, resolve) }).then(() => { return this.results() }) @@ -594,7 +649,7 @@ class Reporter { return this.results() } - results () { + results (): ReporterResults { const tests = _ .chain(this.runnables) .filter({ type: 'test' }) @@ -618,7 +673,10 @@ class Reporter { const { wallClockStartedAt, wallClockEndedAt } = this.stats if (wallClockStartedAt && wallClockEndedAt) { - this.stats.wallClockDuration = wallClockEndedAt - wallClockStartedAt + const startedAt = wallClockStartedAt instanceof Date ? wallClockStartedAt.getTime() : Date.parse(String(wallClockStartedAt)) + const endedAt = wallClockEndedAt instanceof Date ? wallClockEndedAt.getTime() : Date.parse(String(wallClockEndedAt)) + + this.stats.wallClockDuration = endedAt - startedAt } this.stats.suites = suites.length @@ -644,26 +702,25 @@ class Reporter { } } - static setVideoTimestamp (videoStart, tests = []) { - return _.map(tests, (test) => { - // if we have a wallClockStartedAt - let wcs + static setVideoTimestamp (videoStart: Date | number, tests: ReporterTestAttempt[] = []) { + const videoStartMs = videoStart instanceof Date ? videoStart.getTime() : videoStart - wcs = test.wallClockStartedAt + return _.map(tests, (test) => { + const wcs = test.wallClockStartedAt if (wcs) { - test.videoTimestamp = test.wallClockStartedAt - videoStart + test.videoTimestamp = wcs.getTime() - videoStartMs } return test }) } - static create (reporterName, reporterOptions, projectRoot) { + static create (reporterName: string, reporterOptions: Record, projectRoot?: string) { return new Reporter(reporterName, reporterOptions, projectRoot) } - static loadReporter (reporterName, projectRoot) { + static loadReporter (reporterName: string, projectRoot?: string): string | unknown { let p debug('trying to load reporter:', reporterName) @@ -692,7 +749,7 @@ class Reporter { // that is local (./custom-reporter.js) // or one installed by the user through npm try { - p = path.resolve(projectRoot, reporterName) + p = path.resolve(projectRoot ?? '', reporterName) // try local debug('trying to require local reporter with path:', p) @@ -701,15 +758,15 @@ class Reporter { // absolute path as the reporterName which avoids // joining projectRoot unnecessarily return require(p) - } catch (err) { - if (err.code !== 'MODULE_NOT_FOUND') { + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') { // bail early if the error wasn't MODULE_NOT_FOUND // because that means theres something actually wrong // with the found reporter throw err } - p = path.resolve(projectRoot, 'node_modules', reporterName) + p = path.resolve(projectRoot ?? '', 'node_modules', reporterName) // try npm. if this fails, we're out of options, so let it throw debug('trying to require local reporter with path:', p) @@ -718,12 +775,10 @@ class Reporter { } } - static getSearchPathsForReporter (reporterName, projectRoot) { + static getSearchPathsForReporter (reporterName: string, projectRoot?: string): string[] { return _.uniq([ - path.resolve(projectRoot, reporterName), - path.resolve(projectRoot, 'node_modules', reporterName), + path.resolve(projectRoot ?? '', reporterName), + path.resolve(projectRoot ?? '', 'node_modules', reporterName), ]) } } - -module.exports = Reporter diff --git a/packages/server/lib/types/reporter.ts b/packages/server/lib/types/reporter.ts index f09d52464a..df00188e2a 100644 --- a/packages/server/lib/types/reporter.ts +++ b/packages/server/lib/types/reporter.ts @@ -1,21 +1,71 @@ -interface ReporterTestAttempt { - state: 'skipped' | 'failed' | 'passed' - error: any - timings: any - failedFromHookId: any - wallClockStartedAt: Date - wallClockDuration: number - videoTimestamp: any +export type RunnableState = 'passed' | 'failed' | 'pending' | 'skipped' + +interface ReporterCodeFrame { + line: number + column: number + originalFile: string + relativeFile: string + absoluteFile: string + frame: string + language: string } -interface ReporterTest { - testId: string + +export interface ReporterTestError { + name?: string + message?: string + stack?: string + codeFrame?: ReporterCodeFrame +} + +export interface ReporterTestAttempt { + state: RunnableState | null + error: ReporterTestError | null + timings: unknown + failedFromHookId: string | null + wallClockStartedAt: Date | null + wallClockDuration: number | null + videoTimestamp: number | null +} + +export interface ReporterTest { + testId: string | null title: string[] - state: 'skipped' | 'passed' | 'failed' - body: string - displayError: any + state: RunnableState | null + body: string | null + displayError: string | null attempts: ReporterTestAttempt[] } +export interface ReporterHook { + hookId: string | undefined + hookName: string | undefined + title: string[] + body: string | undefined +} + +export interface ReporterStats { + suites: number + tests: number + passes: number + pending: number + skipped: number + failures: number + wallClockDuration: number + wallClockStartedAt?: Date | string + wallClockEndedAt?: Date | string +} + +interface ReporterMochaStats { + suites: number + tests: number + passes: number + pending: number + failures: number + start?: string + end?: string + duration?: number +} + export interface BaseReporterResults { error?: string stats: { @@ -31,18 +81,126 @@ export interface BaseReporterResults { } } -export interface ReporterResults extends BaseReporterResults { +export interface ReporterResults { + error?: string + stats: ReporterStats reporter: string - reporterStats: { - suites: number - tests: number - passes: number - pending: number - failures: number - start: string - end: string - duration: number - } - hooks: any[] + reporterStats: ReporterMochaStats + hooks: ReporterHook[] tests: ReporterTest[] } + +export interface CypressTestStatusInfo { + attempts?: number + strategy?: string + outerStatus?: RunnableState + shouldAttemptsContinue?: boolean +} + +export interface RunnableAttemptPayload { + err?: ReporterTestError | string + state?: RunnableState + timings?: unknown + failedFromHookId?: string + wallClockStartedAt?: number | Date + wallClockDuration?: number +} + +export interface RunnablePayload { + id?: string + title?: string + body?: string + type?: 'test' | 'hook' | 'suite' + state?: RunnableState + root?: boolean + file?: string + duration?: number + timedOut?: boolean + async?: number | boolean + sync?: boolean + err?: ReporterTestError | string + _retries?: number + _currentRetry?: number + currentRetry?: number + retries?: number + hookId?: string + hookName?: string + originalTitle?: string + final?: boolean + failedFromHookId?: string + prevAttempts?: RunnableAttemptPayload[] + _cypressTestStatusInfo?: CypressTestStatusInfo + timings?: unknown + wallClockStartedAt?: number | Date + wallClockDuration?: number + tests?: RunnablePayload[] + suites?: RunnablePayload[] + start?: number | Date + end?: number | Date +} + +export type RetriesConfig = { + experimentalStrategy?: string + experimentalOptions?: Record + runMode?: number | boolean | null + openMode?: number | boolean | null +} + +export type ReporterEventName = + | 'start' + | 'end' + | 'suite' + | 'suite end' + | 'test' + | 'test end' + | 'hook' + | 'retry' + | 'hook end' + | 'pass' + | 'pending' + | 'fail' + | 'test:after:run' + | 'test:before:run' + +type ReporterEventHandler = ( + arg: RunnablePayload, + runnables: Record, + stats: ReporterStats, +) => unknown[] + +export type ReporterEventHandlers = { + [K in ReporterEventName]?: ReporterEventHandler | true +} + +export interface InternalRunnable { + id?: string + title?: string + body?: string + type?: 'test' | 'hook' | 'suite' + state?: RunnableState + root?: boolean + file?: string + duration?: number + timedOut?: boolean + async?: number | boolean + sync?: boolean + err?: ReporterTestError | string + _retries?: number + _currentRetry?: number + hookId?: string + hookName?: string + originalTitle?: string + failedFromHookId?: string + prevAttempts?: RunnableAttemptPayload[] + _cypressTestStatusInfo?: CypressTestStatusInfo + timings?: unknown + wallClockStartedAt?: number | Date + wallClockDuration?: number + parent?: InternalRunnable + tests?: InternalRunnable[] + suites?: InternalRunnable[] + titlePath?: () => string[] + fullTitle?: () => string + speed?: string + retries?: number +} diff --git a/packages/server/lib/util/graceful_crash_handling.ts b/packages/server/lib/util/graceful_crash_handling.ts index 12ad6ce993..5d1f1c915e 100644 --- a/packages/server/lib/util/graceful_crash_handling.ts +++ b/packages/server/lib/util/graceful_crash_handling.ts @@ -1,5 +1,5 @@ import type { ProjectBase } from '../project-base' -import type { BaseReporterResults, ReporterResults } from '../types/reporter' +import type { BaseReporterResults, ReporterResults, ReporterTestError } from '../types/reporter' import { log, stackUtils, stripAnsi } from '@packages/errors' import Debug from 'debug' import pDefer, { DeferredPromise } from 'p-defer' @@ -7,8 +7,8 @@ import pDefer, { DeferredPromise } from 'p-defer' const debug = Debug('cypress:util:crash_handling') /** Matches attempt `error` shape from `reporter.js` `normalizeTest` for Cypress Cloud. */ -export const fatalErrorToAttemptError = (error: Error) => { - const codeFrame = (error as { codeFrame?: unknown }).codeFrame +export const fatalErrorToAttemptError = (error: Error): ReporterTestError => { + const codeFrame = (error as { codeFrame?: ReporterTestError['codeFrame'] }).codeFrame const stackLines = error.stack ? stackUtils.stackWithoutMessage(error.stack) : undefined return { @@ -19,14 +19,27 @@ export const fatalErrorToAttemptError = (error: Error) => { } } +const parseReporterTimestamp = (value?: Date | string): number | undefined => { + if (!value) { + return undefined + } + + if (value instanceof Date) { + return value.getTime() + } + + return Date.parse(value) +} + export const patchRunResultsAfterCrash = ( error: Error, reporterResults: ReporterResults, mostRecentRunnable: { id?: string } | undefined, ): ReporterResults => { - const endTime: number = reporterResults?.stats?.wallClockEndedAt ? Date.parse(reporterResults?.stats?.wallClockEndedAt) : new Date().getTime() - const wallClockDuration = reporterResults?.stats?.wallClockStartedAt ? - endTime - Date.parse(reporterResults.stats.wallClockStartedAt) : 0 + const endTime: number = parseReporterTimestamp(reporterResults?.stats?.wallClockEndedAt) ?? new Date().getTime() + const wallClockStartedAt = parseReporterTimestamp(reporterResults?.stats?.wallClockStartedAt) + const wallClockDuration = wallClockStartedAt ? + endTime - wallClockStartedAt : 0 const endTimeStamp = new Date(endTime).toJSON() // in crash situations, the most recent report will not have the triggering test @@ -127,10 +140,10 @@ export class EarlyExitTerminator { console.log('') log(error) - const runResults: BaseReporterResults = (this.intermediateStats && this.pendingRunnable) ? + const runResults = (this.intermediateStats && this.pendingRunnable) ? patchRunResultsAfterCrash(error, this.intermediateStats, this.pendingRunnable) : defaultStats(error) - this.terminator.resolve(runResults) + this.terminator.resolve(runResults as BaseReporterResults) } } diff --git a/packages/server/test/integration/cypress_spec.js b/packages/server/test/integration/cypress_spec.js index 4c7a0aea64..395e9cf4b5 100644 --- a/packages/server/test/integration/cypress_spec.js +++ b/packages/server/test/integration/cypress_spec.js @@ -32,7 +32,7 @@ const errors = require(`../../lib/errors`) const cypress = require(`../../lib/cypress`) const ProjectBase = require(`../../lib/project-base`).ProjectBase const { ServerBase } = require(`../../lib/server-base`) -const Reporter = require(`../../lib/reporter`) +const { Reporter } = require(`../../lib/reporter`) const browsers = require(`../../lib/browsers`).default const videoCapture = require(`../../lib/video_capture`) const browserUtils = require(`../../lib/browsers/utils`).default diff --git a/packages/server/test/unit/reporter_spec.js b/packages/server/test/unit/reporter_spec.ts similarity index 98% rename from packages/server/test/unit/reporter_spec.js rename to packages/server/test/unit/reporter_spec.ts index 5cc24b3927..303981b294 100644 --- a/packages/server/test/unit/reporter_spec.js +++ b/packages/server/test/unit/reporter_spec.ts @@ -1,7 +1,7 @@ -require('../spec_helper') +import '../spec_helper' -const Reporter = require(`../../lib/reporter`) -const snapshot = require('snap-shot-it') +import { Reporter } from '../../lib/reporter' +import snapshot from 'snap-shot-it' describe('lib/reporter', () => { beforeEach(function () { diff --git a/system-tests/__snapshots__/reporters_spec.js b/system-tests/__snapshots__/reporters_spec.ts.js similarity index 99% rename from system-tests/__snapshots__/reporters_spec.js rename to system-tests/__snapshots__/reporters_spec.ts.js index 852ada71f7..80d1ad0dc0 100644 --- a/system-tests/__snapshots__/reporters_spec.js +++ b/system-tests/__snapshots__/reporters_spec.ts.js @@ -10,7 +10,7 @@ Learn more at https://on.cypress.io/reporters Error: Cannot find module '/foo/bar/.projects/e2e/node_modules/module-does-not-exist' Require stack: -- lib/reporter.js +- lib/reporter.ts [stack trace lines] ` diff --git a/system-tests/test/reporters_spec.js b/system-tests/test/reporters_spec.ts similarity index 100% rename from system-tests/test/reporters_spec.js rename to system-tests/test/reporters_spec.ts