Skip to content

Commit 160f1fc

Browse files
authored
chore(e2e): handle and surface retries + more fixes (#567)
* chore(e2e): fix parsing of JUnit XML file numbers These are all returned as strings, so it's easy to introduce bugs downstream if we don't immediately convert them explicitly to numbers. This fixes some cases where we render `NaN` on the report page. * chore(e2e): fix rendering of skipped tests in main section If all the tests in a suite were individually skipped, we ended up with an empty section and a `Nan%`. * ci(e2e): fix default for workflow_dispatch versions input * chore(e2e): handle retries in next.js e2e results Remove all suites known to be fully passing from the `e2e-skip-retry.json`, add logic to handle retries in the test results, thread this through to the final results, and show retry info on the report page. Every suite was added to this list in order to tame the test run time, but now that we're around a 98% pass rate, the impact of enabling retries should be minimal. * refactor(e2e): simplify and clarify junit2json skip handling * chore(e2e): don't duplicate tests skipped via config These were ending up inserted because they're skipped and inserted a second time if they also ran and failed. The same could technically happen if it passed and was skipped.
1 parent c55e3b9 commit 160f1fc

File tree

8 files changed

+126
-283
lines changed

8 files changed

+126
-283
lines changed

.github/workflows/test-e2e.yml

+4-2
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ on:
99
workflow_dispatch:
1010
inputs:
1111
versions:
12-
description: 'The versions of Next.js to test against (quoted and comma separated)'
12+
description: 'The versions of Next.js to test against (escape-quoted and comma separated)'
1313
required: false
14-
default: "\"latest\""
14+
# TODO(serhalp) Ideally this would simply accept bare quotes but we're having trouble
15+
# parsing that so this will do for now.
16+
default: "\\\"latest\\\""
1517
concurrency:
1618
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
1719
cancel-in-progress: true

e2e-report/app/globals.scss

+5
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,11 @@ button.nav {
341341
td h4 {
342342
font-size: clamp(1rem, 0.5127rem + 1.6242vw, 1.4rem);
343343
}
344+
345+
.retries {
346+
font-style: italic;
347+
font-size: inherit;
348+
}
344349
}
345350

346351
.card {

e2e-report/components/filter-data.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,13 @@ export const OpenIssues = ({ testCases }) => {
8787
<th>Reason</th>
8888
</tr>
8989
{testCases.map((testCase, index) => {
90-
const { name, link, reason = 'Reason not yet assigned' } = testCase
90+
const { name, link, reason = 'Reason not yet assigned', retries } = testCase
9191
return (
9292
<tr key={index}>
93-
<td>{name}</td>
93+
<td>
94+
{name}
95+
{retries > 0 ? ` (🔁 retries: ${retries})` : null}
96+
</td>
9497
<td>
9598
<p>
9699
{link ? (

e2e-report/components/grouped-tests.js

+6
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export default function GroupedTests({ testData }) {
4848
</div>
4949
<div className={`testGroup ${slider[testGroup.id] ? 'open' : 'close'}`}>
5050
{testGroup.results
51+
// We don't want to show skipped tests in this section
52+
.map((suite) => ({
53+
...suite,
54+
testCases: suite.testCases?.filter(({ status }) => status !== 'skipped'),
55+
}))
56+
.filter((suite) => suite.testCases?.length > 0)
5157
.sort(
5258
(aa, bb) =>
5359
(bb.passed || 0) + (bb.failed || 0) - ((aa.passed || 0) + (aa.failed || 0)),

e2e-report/components/table.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export default function Table({ th, name, suitesTotal, total, passed }) {
1+
export default function Table({ th, name, suitesTotal, total, passed, retries }) {
22
return (
33
<table>
44
<tbody>
@@ -21,6 +21,7 @@ export default function Table({ th, name, suitesTotal, total, passed }) {
2121
</td>
2222
<td>
2323
<h4>{Math.round((passed / total) * 100)}%</h4>
24+
{retries > 0 ? <span className="retries"> (⚠️ retries: {retries})</span> : null}
2425
</td>
2526
</tr>
2627
</tbody>

e2e-report/components/test-suites.js

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Table from './table.js'
22

33
export default function TestSuites({ suite, slider, handleSelect, arrows }) {
4-
const { name, file, passed, failed, testCases } = suite
4+
const { name, file, passed, failed, testCases, retries } = suite
55

66
return (
77
<>
@@ -11,17 +11,14 @@ export default function TestSuites({ suite, slider, handleSelect, arrows }) {
1111
name={name}
1212
passed={passed}
1313
total={passed + failed}
14+
retries={retries}
1415
/>
1516
{arrows(slider[name])}
1617
</div>
1718
<ul className={`testCases ${slider[name] ? 'open' : 'close'}`}>
1819
{testCases?.map((testCase, index) => {
1920
const { status, link, reason } = testCase
2021

21-
if (status === 'skipped') {
22-
return
23-
}
24-
2522
return (
2623
<li key={`${name}${index}`}>
2724
<a target="_blank" href={`https://github.com/vercel/next.js/tree/canary/${file}`}>

tests/e2e-skip-retry.json

+16-243
Large diffs are not rendered by default.

tools/deno/junit2json.ts

+86-30
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface TestSuite {
3939
skipped: number
4040
total: number
4141
testCases: TestCase[]
42+
retries: number
4243
}
4344

4445
interface SkippedTestSuite {
@@ -53,6 +54,7 @@ interface TestCase {
5354
status: 'passed' | 'failed' | 'skipped'
5455
reason?: string
5556
link?: string
57+
retries: number
5658
}
5759

5860
async function parseXMLFile(filePath: string): Promise<{ testsuites: JUnitTestSuites }> {
@@ -66,9 +68,7 @@ const testCount = {
6668
passed: 0,
6769
}
6870

69-
function junitToJson(xmlData: {
70-
testsuites: JUnitTestSuites
71-
}): Array<TestSuite | SkippedTestSuite> {
71+
function junitToJson(xmlData: { testsuites: JUnitTestSuites }): Array<TestSuite> {
7272
if (!xmlData.testsuites) {
7373
return []
7474
}
@@ -78,38 +78,57 @@ function junitToJson(xmlData: {
7878
: [xmlData.testsuites.testsuite]
7979

8080
return testSuites.map((suite) => {
81-
const { '@tests': tests, '@failures': failed, '@name': name } = suite
82-
83-
const passed = tests - failed - suite['@skipped']
84-
81+
const total = Number(suite['@tests'])
82+
const failed = Number(suite['@failures']) + Number(suite['@errors'])
83+
const name = suite['@name']
8584
const testCases = Array.isArray(suite.testcase) ? suite.testcase : [suite.testcase]
8685

86+
const passed = total - failed - suite['@skipped']
8787
const testSuite: TestSuite = {
8888
name,
8989
file: testCases[0]?.['@file'],
9090
passed,
91-
failed: Number(failed),
91+
failed,
92+
// The XML file contains a count of "skipped" tests, but we actually want to report on what WE
93+
// want to "skip" (i.e. the tests that are marked as skipped in `test-config.json`). This is
94+
// confusing and we should probably use a different term for our concept.
9295
skipped: 0,
93-
total: tests,
96+
total,
9497
testCases: [],
98+
// This is computed below by detecting duplicates
99+
retries: 0,
95100
}
96-
const skippedTestsForFile = testConfig.skipped.find(
101+
const skipConfigForFile = testConfig.skipped.find(
97102
(skippedTest) => skippedTest.file === testSuite.file,
98103
)
104+
const isEntireSuiteSkipped = skipConfigForFile != null && skipConfigForFile.tests == null
105+
const skippedTestsForFile =
106+
skipConfigForFile?.tests?.map((skippedTest) => {
107+
// The config supports both a bare string and an object
108+
if (typeof skippedTest === 'string') {
109+
return { name: skippedTest, reason: skipConfigForFile.reason ?? null }
110+
}
111+
return skippedTest
112+
}) ?? []
99113

100114
// If the skipped file has no `tests`, all tests in the file are skipped
101-
testSuite.skipped =
102-
skippedTestsForFile != null ? (skippedTestsForFile.tests ?? testCases).length : 0
115+
testSuite.skipped = isEntireSuiteSkipped ? testCases.length : skippedTestsForFile.length
103116

104117
for (const testCase of testCases) {
118+
// Omit tests skipped in the Next.js repo itself
105119
if ('skipped' in testCase) {
106120
continue
107121
}
122+
// Omit tests we've marked as "skipped" in `test-config.json`
123+
if (skippedTestsForFile?.some(({ name }) => name === testCase['@name'])) {
124+
continue
125+
}
108126
const status = testCase.failure ? 'failed' : 'passed'
109127
testCount[status]++
110128
const test: TestCase = {
111129
name: testCase['@name'],
112130
status,
131+
retries: 0,
113132
}
114133
if (status === 'failed') {
115134
const failure = testConfig.failures.find(
@@ -123,36 +142,74 @@ function junitToJson(xmlData: {
123142
testSuite.testCases.push(test)
124143
}
125144

126-
if (skippedTestsForFile?.tests) {
127-
testCount.skipped += skippedTestsForFile.tests.length
145+
if (!isEntireSuiteSkipped && skippedTestsForFile.length > 0) {
146+
testCount.skipped += skippedTestsForFile.length
128147
testSuite.testCases.push(
129-
...skippedTestsForFile.tests.map((test): TestCase => {
130-
if (typeof test === 'string') {
131-
return {
132-
name: test,
133-
status: 'skipped',
134-
reason: skippedTestsForFile.reason,
135-
}
136-
}
137-
return {
148+
...skippedTestsForFile.map(
149+
(test): TestCase => ({
138150
name: test.name,
139151
status: 'skipped',
140152
reason: test.reason,
141-
}
142-
}),
153+
retries: 0,
154+
}),
155+
),
143156
)
144-
} else if (skippedTestsForFile != null) {
145-
// If `tests` is omitted, all tests in the file are skipped
157+
} else if (isEntireSuiteSkipped) {
146158
testCount.skipped += testSuite.total
147159
}
148160
return testSuite
149161
})
150162
}
151163

164+
function mergeTestResults(result1: TestSuite, result2: TestSuite): TestSuite {
165+
if (result1.file !== result2.file) {
166+
throw new Error('Cannot merge results for different files')
167+
}
168+
if (result1.name !== result2.name) {
169+
throw new Error('Cannot merge results for different suites')
170+
}
171+
if (result1.total !== result2.total) {
172+
throw new Error('Cannot merge results with different total test counts')
173+
}
174+
175+
// Return the run result with the fewest failures.
176+
// We could merge at the individual test level across runs, but then we'd need to re-calculate
177+
// all the total counts, and that probably isn't worth the complexity.
178+
const bestResult = result1.failed < result2.failed ? result1 : result2
179+
const retries = result1.retries + result2.retries + 1
180+
return {
181+
...bestResult,
182+
retries,
183+
...(bestResult.testCases == null
184+
? {}
185+
: {
186+
testCases: bestResult.testCases.map((testCase) => ({
187+
...testCase,
188+
retries,
189+
})),
190+
}),
191+
}
192+
}
193+
194+
// When a test is run multiple times (due to retries), the test runner outputs a separate entry
195+
// for each run. Merge them into a single entry.
196+
function dedupeTestResults(results: Array<TestSuite>): Array<TestSuite> {
197+
const resultsByFile = new Map<string, TestSuite>()
198+
for (const result of results) {
199+
const existingResult = resultsByFile.get(result.file)
200+
if (existingResult == null) {
201+
resultsByFile.set(result.file, result)
202+
} else {
203+
resultsByFile.set(result.file, mergeTestResults(existingResult, result))
204+
}
205+
}
206+
return [...resultsByFile.values()]
207+
}
208+
152209
async function processJUnitFiles(
153210
directoryPath: string,
154211
): Promise<Array<TestSuite | SkippedTestSuite>> {
155-
const results: (TestSuite | SkippedTestSuite)[] = []
212+
const results: TestSuite[] = []
156213
for await (const file of expandGlob(`${directoryPath}/**/*.xml`)) {
157214
const xmlData = await parseXMLFile(file.path)
158215
results.push(...junitToJson(xmlData))
@@ -170,9 +227,8 @@ async function processJUnitFiles(
170227
skipped: true,
171228
}),
172229
)
173-
results.push(...skippedSuites)
174230

175-
return results
231+
return [...dedupeTestResults(results), ...skippedSuites]
176232
}
177233

178234
// Get the directory path from the command-line arguments

0 commit comments

Comments
 (0)