Skip to content

Commit 1a6f879

Browse files
authored
fix: report results correctly when the browser crashes mid-test (#27786)
* report correct number of failures if browser crashes in the middle of a test suite * report failure correctly when browser crashes during test * refactor crash handling * correct exit option check, clean up debug * exit on success if exit option !== false * use default stats when reporter stats are unavailable * fix error messaging * move reporter types to an intermediate .ts file, to be combined with reporter.js when migrated * debug tab close test in ci * move debug env from pkg to ci yml * set debug env in spec * fix pckg * adds some logging to cri-client * remove event emit logging from project-base * revert snapshot for tab close system test * fixes console output for no exit on success * changelog * changelog wsp * cleanup * clean up tests * refactor to more straightforward control flow * rm export for unused type * correct tab close snapshot for ci * new system test for mid-test config crash * update snapshots
1 parent 92cc6da commit 1a6f879

File tree

11 files changed

+1790
-1506
lines changed

11 files changed

+1790
-1506
lines changed

cli/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ _Released 10/03/2023 (PENDING)_
88
- Fixed an issue where requests were correlated in the wrong order in the proxy. This could cause an issue where the wrong request is used for `cy.intercept` or assets (e.g. stylesheets or images) may not properly be available in Test Replay. Addressed in [#27892](https://github.com/cypress-io/cypress/pull/27892).
99
- Fixed an issue where a crashed Chrome renderer can cause the Test Replay recorder to hang. Addressed in [#27909](https://github.com/cypress-io/cypress/pull/27909).
1010
- Fixed an issue where multiple responses yielded from calls to `cy.wait()` would sometimes be out of order. Fixes [#27337](https://github.com/cypress-io/cypress/issues/27337).
11+
- Enables test replay for executed specs in runs that have a spec that causes a browser crash. Addressed in [#27786](https://github.com/cypress-io/cypress/pull/27786)
1112

1213
## 13.3.0
1314

packages/server/lib/modes/run.ts

+40-53
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ import random from '../util/random'
2121
import system from '../util/system'
2222
import chromePolicyCheck from '../util/chrome_policy_check'
2323
import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts, FoundBrowser, BrowserVideoController, VideoRecording, ProcessOptions } from '@packages/types'
24-
import type { Cfg } from '../project-base'
24+
import type { Cfg, ProjectBase } from '../project-base'
2525
import type { Browser } from '../browsers/types'
2626
import * as printResults from '../util/print-run'
2727
import type { ProtocolManager } from '../cloud/protocol'
2828
import { telemetry } from '@packages/telemetry'
2929
import { CypressRunResult, createPublicBrowser, createPublicConfig, createPublicRunResults, createPublicSpec, createPublicSpecResults } from './results'
30+
import { EarlyExitTerminator } from '../util/graceful_crash_handling'
3031

3132
type SetScreenshotMetadata = (data: TakeScreenshotProps) => void
3233
type ScreenshotMetadata = ReturnType<typeof screenshotMetadata>
@@ -36,18 +37,13 @@ type BeforeSpecRun = any
3637
type AfterSpecRun = any
3738
type Project = NonNullable<ReturnType<typeof openProject['getProject']>>
3839

39-
let exitEarly = (err) => {
40-
debug('set early exit error: %s', err.stack)
41-
42-
earlyExitErr = err
43-
}
44-
let earlyExitErr: Error
4540
let currentSetScreenshotMetadata: SetScreenshotMetadata
4641

4742
const debug = Debug('cypress:server:run')
48-
4943
const DELAY_TO_LET_VIDEO_FINISH_MS = 1000
5044

45+
let earlyExitTerminator = new EarlyExitTerminator()
46+
5147
const relativeSpecPattern = (projectRoot, pattern) => {
5248
if (typeof pattern === 'string') {
5349
return pattern.replace(`${projectRoot}/`, '')
@@ -411,53 +407,30 @@ function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot,
411407
return openProject.launch(browser, spec, browserOpts)
412408
}
413409

414-
function listenForProjectEnd (project, exit): Bluebird<any> {
410+
async function listenForProjectEnd (project: ProjectBase, exit: boolean): Promise<any> {
415411
if (globalThis.CY_TEST_MOCK?.listenForProjectEnd) return Bluebird.resolve(globalThis.CY_TEST_MOCK.listenForProjectEnd)
416412

417-
return new Bluebird((resolve, reject) => {
418-
if (exit === false) {
419-
resolve = () => {
413+
// if exit is false, we need to intercept the resolution of tests - whether
414+
// an early exit with intermediate results, or a full run.
415+
return new Promise((resolve, reject) => {
416+
Promise.race([
417+
new Promise((res) => {
418+
project.once('end', (results) => {
419+
debug('project ended with results %O', results)
420+
res(results)
421+
})
422+
}),
423+
earlyExitTerminator.waitForEarlyExit(project, exit),
424+
]).then((results) => {
425+
if (exit === false) {
426+
// eslint-disable-next-line no-console
420427
console.log('not exiting due to options.exit being false')
428+
} else {
429+
resolve(results)
421430
}
422-
}
423-
424-
const onEarlyExit = function (err) {
425-
if (err.isFatalApiErr) {
426-
return reject(err)
427-
}
428-
429-
console.log('')
430-
errors.log(err)
431-
432-
const obj = {
433-
error: errors.stripAnsi(err.message),
434-
stats: {
435-
failures: 1,
436-
tests: 0,
437-
passes: 0,
438-
pending: 0,
439-
suites: 0,
440-
skipped: 0,
441-
wallClockDuration: 0,
442-
wallClockStartedAt: new Date().toJSON(),
443-
wallClockEndedAt: new Date().toJSON(),
444-
},
445-
}
446-
447-
return resolve(obj)
448-
}
449-
450-
project.once('end', (results) => resolve(results))
451-
452-
// if we already received a reason to exit early, go ahead and do it
453-
if (earlyExitErr) {
454-
return onEarlyExit(earlyExitErr)
455-
}
456-
457-
// otherwise override exitEarly so we exit as soon as there is a reason
458-
exitEarly = (err) => {
459-
onEarlyExit(err)
460-
}
431+
}).catch((err) => {
432+
reject(err)
433+
})
461434
})
462435
}
463436

@@ -730,6 +703,13 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens
730703
results.video = null
731704
}
732705

706+
// the early exit terminator persists between specs,
707+
// so if this spec crashed, the next one will report as
708+
// a crash too unless it is reset. Would like to not rely
709+
// on closure, but threading through fn props via options is also not
710+
// great.
711+
earlyExitTerminator = new EarlyExitTerminator()
712+
733713
return results
734714
}
735715

@@ -1005,7 +985,11 @@ async function ready (options: ReadyOptions) {
1005985
// this needs to be a closure over `exitEarly` and not a reference
1006986
// because `exitEarly` gets overwritten in `listenForProjectEnd`
1007987
// TODO: refactor this so we don't need to extend options
1008-
const onError = options.onError = (err) => exitEarly(err)
988+
989+
const onError = options.onError = (err) => {
990+
debug('onError')
991+
earlyExitTerminator.exitEarly(err)
992+
}
1009993

1010994
// alias and coerce to null
1011995
let specPatternFromCli = options.spec || null
@@ -1140,6 +1124,7 @@ async function ready (options: ReadyOptions) {
11401124
}
11411125

11421126
export async function run (options, loading: Promise<void>) {
1127+
debug('run start')
11431128
// Check if running as electron process
11441129
if (require('../util/electron-app').isRunningAsElectronProcess({ debug })) {
11451130
const app = require('electron').app
@@ -1161,6 +1146,8 @@ export async function run (options, loading: Promise<void>) {
11611146
try {
11621147
return ready(options)
11631148
} catch (e) {
1164-
return exitEarly(e)
1149+
debug('caught outer error', e)
1150+
1151+
return earlyExitTerminator.exitEarly(e)
11651152
}
11661153
}

packages/server/lib/project-base.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,6 @@ export class ProjectBase extends EE {
376376
},
377377

378378
onMocha: async (event, runnable) => {
379-
debug('onMocha', event)
380379
// bail if we dont have a
381380
// reporter instance
382381
if (!reporterInstance) {
@@ -385,7 +384,12 @@ export class ProjectBase extends EE {
385384

386385
reporterInstance.emit(event, runnable)
387386

388-
if (event === 'end') {
387+
if (event === 'test:before:run') {
388+
this.emit('test:before:run', {
389+
runnable,
390+
previousResults: reporterInstance?.results() || {},
391+
})
392+
} else if (event === 'end') {
389393
const [stats = {}] = await Promise.all([
390394
(reporterInstance != null ? reporterInstance.end() : undefined),
391395
this.server.end(),

packages/server/lib/types/reporter.ts

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
interface ReporterTestAttempt {
2+
state: 'skipped' | 'failed' | 'passed'
3+
error: any
4+
timings: any
5+
failedFromHookId: any
6+
wallClockStartedAt: Date
7+
wallClockDuration: number
8+
videoTimestamp: any
9+
}
10+
interface ReporterTest {
11+
testId: string
12+
title: string[]
13+
state: 'skipped' | 'passed' | 'failed'
14+
body: string
15+
displayError: any
16+
attempts: ReporterTestAttempt[]
17+
}
18+
19+
export interface BaseReporterResults {
20+
error?: string
21+
stats: {
22+
failures: number
23+
tests: number
24+
passes: number
25+
pending: number
26+
suites: number
27+
skipped: number
28+
wallClockDuration: number
29+
wallClockStartedAt: string
30+
wallClockEndedAt: string
31+
}
32+
}
33+
34+
export interface ReporterResults extends BaseReporterResults {
35+
reporter: string
36+
reporterStats: {
37+
suites: number
38+
tests: number
39+
passes: number
40+
pending: number
41+
failures: number
42+
start: string
43+
end: string
44+
duration: number
45+
}
46+
hooks: any[]
47+
tests: ReporterTest[]
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { ProjectBase } from '../project-base'
2+
import type { BaseReporterResults, ReporterResults } from '../types/reporter'
3+
import * as errors from '../errors'
4+
import Debug from 'debug'
5+
import pDefer, { DeferredPromise } from 'p-defer'
6+
7+
const debug = Debug('cypress:util:crash_handling')
8+
9+
const patchRunResultsAfterCrash = (error: Error, reporterResults: ReporterResults, mostRecentRunnable: any): ReporterResults => {
10+
const endTime: number = reporterResults?.stats?.wallClockEndedAt ? Date.parse(reporterResults?.stats?.wallClockEndedAt) : new Date().getTime()
11+
const wallClockDuration = reporterResults?.stats?.wallClockStartedAt ?
12+
endTime - Date.parse(reporterResults.stats.wallClockStartedAt) : 0
13+
const endTimeStamp = new Date(endTime).toJSON()
14+
15+
// in crash situations, the most recent report will not have the triggering test
16+
// so the results are manually patched, which produces the expected exit=1 and
17+
// terminal output indicating the failed test
18+
return {
19+
...reporterResults,
20+
stats: {
21+
...reporterResults?.stats,
22+
wallClockEndedAt: endTimeStamp,
23+
wallClockDuration,
24+
failures: (reporterResults?.stats?.failures ?? 0) + 1,
25+
skipped: (reporterResults?.stats?.skipped ?? 1) - 1,
26+
},
27+
reporterStats: {
28+
...reporterResults?.reporterStats,
29+
tests: (reporterResults?.reporterStats?.tests ?? 0) + 1, // crashed test does not increment this value
30+
end: reporterResults?.reporterStats?.end || endTimeStamp,
31+
duration: wallClockDuration,
32+
failures: (reporterResults?.reporterStats?.failures ?? 0) + 1,
33+
},
34+
tests: (reporterResults?.tests || []).map((test) => {
35+
if (test.testId === mostRecentRunnable.id) {
36+
return {
37+
...test,
38+
state: 'failed',
39+
attempts: [
40+
...test.attempts.slice(0, -1),
41+
{
42+
...test.attempts[test.attempts.length - 1],
43+
state: 'failed',
44+
},
45+
],
46+
}
47+
}
48+
49+
return test
50+
}),
51+
error: errors.stripAnsi(error.message),
52+
}
53+
}
54+
55+
const defaultStats = (error: Error): BaseReporterResults => {
56+
return {
57+
error: errors.stripAnsi(error.message),
58+
stats: {
59+
failures: 1,
60+
tests: 0,
61+
passes: 0,
62+
pending: 0,
63+
suites: 0,
64+
skipped: 0,
65+
wallClockDuration: 0,
66+
wallClockStartedAt: new Date().toJSON(),
67+
wallClockEndedAt: new Date().toJSON(),
68+
},
69+
}
70+
}
71+
72+
export class EarlyExitTerminator {
73+
private terminator: DeferredPromise<BaseReporterResults>
74+
75+
private pendingRunnable: any
76+
private intermediateStats: ReporterResults | undefined
77+
78+
constructor () {
79+
this.terminator = pDefer<BaseReporterResults>()
80+
}
81+
82+
waitForEarlyExit (project: ProjectBase, exit?: boolean) {
83+
debug('waiting for early exit')
84+
85+
project.on('test:before:run', ({
86+
runnable,
87+
previousResults,
88+
}) => {
89+
debug('preparing to run test, previous stats reported as %O', previousResults)
90+
91+
this.intermediateStats = previousResults
92+
this.pendingRunnable = runnable
93+
})
94+
95+
return this.terminator.promise
96+
}
97+
98+
exitEarly (error) {
99+
if (error.isFatalApiErr) {
100+
this.terminator.reject(error)
101+
102+
return
103+
}
104+
105+
// eslint-disable-next-line no-console
106+
console.log('')
107+
errors.log(error)
108+
109+
const runResults: BaseReporterResults = (this.intermediateStats && this.pendingRunnable) ?
110+
patchRunResultsAfterCrash(error, this.intermediateStats, this.pendingRunnable) :
111+
defaultStats(error)
112+
113+
this.terminator.resolve(runResults)
114+
}
115+
}

0 commit comments

Comments
 (0)