Skip to content

Commit ddc05e5

Browse files
chore: Attach fatal run errors to the active test for Cloud results (#33617)
1 parent e5f0b32 commit ddc05e5

4 files changed

Lines changed: 225 additions & 16 deletions

File tree

packages/server/lib/errors.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,3 @@ export const warning = errors.warning
2828
export const throwErr = errors.throwErr
2929

3030
export const cloneErr = errors.cloneErr
31-
32-
export const stripAnsi = errors.stripAnsi

packages/server/lib/util/graceful_crash_handling.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,38 @@
11
import type { ProjectBase } from '../project-base'
22
import type { BaseReporterResults, ReporterResults } from '../types/reporter'
3-
import * as errors from '../errors'
3+
import { log, stackUtils, stripAnsi } from '@packages/errors'
44
import Debug from 'debug'
55
import pDefer, { DeferredPromise } from 'p-defer'
66

77
const debug = Debug('cypress:util:crash_handling')
88

9-
const patchRunResultsAfterCrash = (error: Error, reporterResults: ReporterResults, mostRecentRunnable: any): ReporterResults => {
9+
/** Matches attempt `error` shape from `reporter.js` `normalizeTest` for Cypress Cloud. */
10+
export const fatalErrorToAttemptError = (error: Error) => {
11+
const codeFrame = (error as { codeFrame?: unknown }).codeFrame
12+
const stackLines = error.stack ? stackUtils.stackWithoutMessage(error.stack) : undefined
13+
14+
return {
15+
name: error.name,
16+
message: stripAnsi(error.message),
17+
stack: stackLines !== undefined ? stripAnsi(stackLines) : undefined,
18+
...(codeFrame !== undefined ? { codeFrame } : {}),
19+
}
20+
}
21+
22+
export const patchRunResultsAfterCrash = (
23+
error: Error,
24+
reporterResults: ReporterResults,
25+
mostRecentRunnable: { id?: string } | undefined,
26+
): ReporterResults => {
1027
const endTime: number = reporterResults?.stats?.wallClockEndedAt ? Date.parse(reporterResults?.stats?.wallClockEndedAt) : new Date().getTime()
1128
const wallClockDuration = reporterResults?.stats?.wallClockStartedAt ?
1229
endTime - Date.parse(reporterResults.stats.wallClockStartedAt) : 0
1330
const endTimeStamp = new Date(endTime).toJSON()
1431

1532
// in crash situations, the most recent report will not have the triggering test
1633
// so the results are manually patched, which produces the expected exit=1 and
17-
// terminal output indicating the failed test
34+
// terminal output indicating the failed test. Per-attempt `error` + `displayError`
35+
// are set so Cypress Cloud can show the fatal/config message on the impacted test.
1836
return {
1937
...reporterResults,
2038
stats: {
@@ -32,29 +50,32 @@ const patchRunResultsAfterCrash = (error: Error, reporterResults: ReporterResult
3250
failures: (reporterResults?.reporterStats?.failures ?? 0) + 1,
3351
},
3452
tests: (reporterResults?.tests || []).map((test) => {
35-
if (test.testId === mostRecentRunnable.id) {
53+
if (test.testId === mostRecentRunnable?.id) {
54+
const prevAttempts = test.attempts.slice(0, -1)
55+
const lastAttempt = test.attempts[test.attempts.length - 1]
56+
const attemptError = fatalErrorToAttemptError(error)
57+
3658
return {
3759
...test,
3860
state: 'failed',
39-
attempts: [
40-
...test.attempts.slice(0, -1),
41-
{
42-
...test.attempts[test.attempts.length - 1],
43-
state: 'failed',
44-
},
45-
],
61+
displayError: stripAnsi(error.stack || error.message),
62+
attempts: [...prevAttempts, {
63+
...lastAttempt,
64+
state: 'failed',
65+
error: attemptError,
66+
}],
4667
}
4768
}
4869

4970
return test
5071
}),
51-
error: errors.stripAnsi(error.message),
72+
error: stripAnsi(error.message),
5273
}
5374
}
5475

5576
const defaultStats = (error: Error): BaseReporterResults => {
5677
return {
57-
error: errors.stripAnsi(error.message),
78+
error: stripAnsi(error.message),
5879
stats: {
5980
failures: 1,
6081
tests: 0,
@@ -104,7 +125,7 @@ export class EarlyExitTerminator {
104125

105126
// eslint-disable-next-line no-console
106127
console.log('')
107-
errors.log(error)
128+
log(error)
108129

109130
const runResults: BaseReporterResults = (this.intermediateStats && this.pendingRunnable) ?
110131
patchRunResultsAfterCrash(error, this.intermediateStats, this.pendingRunnable) :
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Intentionally omit spec_helper: it pulls in lib/cache before this file's imports; that chain
2+
// fails under some Node/ts-node setups. Chai's `expect` is sufficient for this pure unit test.
3+
import { expect } from 'chai'
4+
5+
import type { ReporterResults } from '../../../lib/types/reporter'
6+
import { patchRunResultsAfterCrash } from '../../../lib/util/graceful_crash_handling'
7+
8+
const baseReporterResults = (): ReporterResults => ({
9+
reporter: 'spec',
10+
reporterStats: {
11+
suites: 1,
12+
tests: 1,
13+
passes: 0,
14+
pending: 0,
15+
failures: 0,
16+
start: new Date(0).toJSON(),
17+
end: new Date(0).toJSON(),
18+
duration: 0,
19+
},
20+
hooks: [],
21+
stats: {
22+
failures: 0,
23+
tests: 1,
24+
passes: 0,
25+
pending: 0,
26+
suites: 1,
27+
skipped: 1,
28+
wallClockDuration: 0,
29+
wallClockStartedAt: new Date(0).toJSON(),
30+
wallClockEndedAt: new Date(0).toJSON(),
31+
},
32+
tests: [
33+
{
34+
testId: 'r1',
35+
title: ['Suite', 'fails on crash'],
36+
state: 'skipped',
37+
body: '',
38+
displayError: null,
39+
attempts: [{
40+
state: 'skipped',
41+
error: null,
42+
timings: null,
43+
failedFromHookId: null,
44+
wallClockStartedAt: new Date(0),
45+
wallClockDuration: 0,
46+
videoTimestamp: null,
47+
}],
48+
},
49+
],
50+
})
51+
52+
describe('lib/util/graceful_crash_handling', () => {
53+
describe('patchRunResultsAfterCrash', () => {
54+
it('sets last attempt error and displayError when runnable id matches', () => {
55+
const fatal = new Error('Your configFile threw an error')
56+
57+
fatal.stack = `Error: Your configFile threw an error\n at cfg (cypress.config.js:1:1)`
58+
59+
const out = patchRunResultsAfterCrash(fatal, baseReporterResults(), { id: 'r1' })
60+
61+
expect(out.error).to.include('Your configFile threw an error')
62+
63+
const test = out.tests[0]
64+
65+
expect(test.state).to.eq('failed')
66+
expect(test.displayError).to.eq(fatal.stack)
67+
expect(test.attempts).to.have.length(1)
68+
expect(test.attempts[0].state).to.eq('failed')
69+
expect(test.attempts[0].error).to.include({
70+
name: 'Error',
71+
message: 'Your configFile threw an error',
72+
})
73+
74+
expect(test.attempts[0].error.stack).to.eq(` at cfg (cypress.config.js:1:1)`)
75+
})
76+
77+
it('serializes attempt stack without message line (matches reporter normalizeTest)', () => {
78+
const err = new Error('config blew up')
79+
80+
err.stack = `Error: config blew up\n at foo (bar.js:1:1)`
81+
82+
const out = patchRunResultsAfterCrash(err, baseReporterResults(), { id: 'r1' })
83+
const attemptErr = out.tests[0].attempts[0].error
84+
85+
expect(attemptErr?.name).to.eq('Error')
86+
expect(attemptErr?.message).to.eq('config blew up')
87+
expect(attemptErr?.stack).to.eq(` at foo (bar.js:1:1)`)
88+
})
89+
90+
it('strips ANSI from displayError and attempt fields for Cypress errors', () => {
91+
const fatal = new Error(
92+
'Your \u001b[33mconfigFile\u001b[39m threw an error from: \u001b[94mcypress.config.js\u001b[39m\n\nWe stopped running your tests because your config file crashed.',
93+
)
94+
95+
fatal.stack = `${fatal.message}\n at x (y:1:1)`
96+
97+
const out = patchRunResultsAfterCrash(fatal, baseReporterResults(), { id: 'r1' })
98+
99+
expect(out.tests[0].displayError).to.not.include('\u001b[')
100+
expect(out.tests[0].attempts[0].error?.message).to.not.include('\u001b[')
101+
expect(out.tests[0].attempts[0].error?.message).to.include('configFile')
102+
expect(out.tests[0].attempts[0].error?.message).to.include('cypress.config.js')
103+
expect(out.tests[0].attempts[0].error?.stack).to.not.include('\u001b[')
104+
})
105+
106+
it('does not throw and does not patch tests when mostRecentRunnable is undefined', () => {
107+
const fatal = new Error('boom')
108+
const results = baseReporterResults()
109+
const out = patchRunResultsAfterCrash(fatal, results, undefined)
110+
111+
expect(out.tests[0].state).to.eq('skipped')
112+
expect(out.tests[0].attempts[0].error).to.eq(null)
113+
})
114+
115+
it('does not patch test when runnable id does not match a test (stats still reflect fatal)', () => {
116+
const fatal = new Error('config process died')
117+
const results = baseReporterResults()
118+
119+
results.tests.push({
120+
testId: 'r2',
121+
title: ['Suite', 'other'],
122+
state: 'passed',
123+
body: '',
124+
displayError: null,
125+
attempts: [{
126+
state: 'passed',
127+
error: null,
128+
timings: null,
129+
failedFromHookId: null,
130+
wallClockStartedAt: new Date(0),
131+
wallClockDuration: 1,
132+
videoTimestamp: null,
133+
}],
134+
})
135+
136+
const out = patchRunResultsAfterCrash(fatal, results, { id: 'nonexistent' })
137+
138+
expect(out.tests[0].state).to.eq('skipped')
139+
expect(out.tests[0].attempts[0].error).to.eq(null)
140+
expect(out.tests[1].state).to.eq('passed')
141+
expect(out.tests[1].attempts[0].error).to.eq(null)
142+
expect(out.stats.failures).to.equal(results.stats.failures + 1)
143+
})
144+
145+
it('only replaces the last attempt when there are prior attempts (retries)', () => {
146+
const fatal = new Error('tab crashed')
147+
const results = baseReporterResults()
148+
149+
results.tests[0].attempts = [
150+
{
151+
state: 'failed',
152+
error: { name: 'Error', message: 'first flake', stack: 'at a' },
153+
timings: null,
154+
failedFromHookId: null,
155+
wallClockStartedAt: new Date(0),
156+
wallClockDuration: 1,
157+
videoTimestamp: null,
158+
},
159+
{
160+
state: 'skipped',
161+
error: null,
162+
timings: null,
163+
failedFromHookId: null,
164+
wallClockStartedAt: new Date(1),
165+
wallClockDuration: 0,
166+
videoTimestamp: null,
167+
},
168+
]
169+
170+
const out = patchRunResultsAfterCrash(fatal, results, { id: 'r1' })
171+
172+
expect(out.tests[0].attempts).to.have.length(2)
173+
expect(out.tests[0].attempts[0].error).to.deep.include({ message: 'first flake' })
174+
expect(out.tests[0].attempts[1].state).to.eq('failed')
175+
expect(out.tests[0].attempts[1].error?.message).to.eq('tab crashed')
176+
})
177+
})
178+
})

system-tests/test/record_spec.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2721,6 +2721,18 @@ describe('e2e record', () => {
27212721
expect(postResultsRequest.body.stats.passes).to.equal(1)
27222722
expect(postResultsRequest.body.stats.failures).to.equal(1)
27232723
expect(postResultsRequest.body.stats.skipped).to.equal(0)
2724+
2725+
// Early-exit crash patching should attach structured error to the impacted test for Cloud
2726+
const failedWithCrashOnAttempt = postResultsRequest.body.tests.filter((t) => {
2727+
if (t.state !== 'failed' || !t.attempts?.length) return false
2728+
2729+
const err = t.attempts[t.attempts.length - 1].error
2730+
2731+
return err && err.message && err.message.includes('Chrome')
2732+
})
2733+
2734+
expect(failedWithCrashOnAttempt, 'failed test should carry crash error on last attempt').to.have.length.greaterThan(0)
2735+
expect(failedWithCrashOnAttempt[0].displayError).to.be.a('string').and.not.empty
27242736
})
27252737
})
27262738
})

0 commit comments

Comments
 (0)