Skip to content

Commit 0007c7b

Browse files
authored
ci: Extract test names for flaky test issues (#20298)
Extract the exact failing test name from GitHub check annotations (via Vitest github-actions and Playwright github reporters) and include it in the issue title. This allows us to change issue deduplication from per job to per test. Previously, if test A flaked and created an issue for "Node 18 Integration Tests", a later flake of test B for the same job would be skipped; now each failing test gets its own issue. [Example issue](#20315)
1 parent 9b9d65c commit 0007c7b

File tree

7 files changed

+125
-70
lines changed

7 files changed

+125
-70
lines changed

.github/FLAKY_CI_FAILURE_TEMPLATE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
title: '[Flaky CI]: {{ env.JOB_NAME }}'
2+
title: '[Flaky CI]: {{ env.JOB_NAME }} - {{ env.TEST_NAME }}'
33
labels: Tests
44
---
55

@@ -13,7 +13,7 @@ Other / Unknown
1313

1414
### Name of Test
1515

16-
_Not available - check the run link for details_
16+
{{ env.TEST_NAME }}
1717

1818
### Link to Test Run
1919

.github/workflows/build.yml

Lines changed: 8 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,82 +1155,25 @@ jobs:
11551155
runs-on: ubuntu-24.04
11561156
permissions:
11571157
issues: write
1158+
checks: read
11581159
steps:
11591160
- name: Check out current commit
11601161
if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure')
11611162
uses: actions/checkout@v6
11621163
with:
1163-
sparse-checkout: .github
1164+
sparse-checkout: |
1165+
.github
1166+
scripts
11641167
11651168
- name: Create issues for failed jobs
11661169
if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure')
11671170
uses: actions/github-script@v7
11681171
with:
11691172
script: |
1170-
const fs = require('fs');
1171-
1172-
// Fetch actual job details from the API to get descriptive names
1173-
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
1174-
owner: context.repo.owner,
1175-
repo: context.repo.repo,
1176-
run_id: context.runId,
1177-
per_page: 100
1178-
});
1179-
1180-
const failedJobs = jobs.filter(job => job.conclusion === 'failure' && !job.name.includes('(optional)'));
1181-
1182-
if (failedJobs.length === 0) {
1183-
console.log('No failed jobs found');
1184-
return;
1185-
}
1186-
1187-
// Read and parse template
1188-
const template = fs.readFileSync('.github/FLAKY_CI_FAILURE_TEMPLATE.md', 'utf8');
1189-
const [, frontmatter, bodyTemplate] = template.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1190-
1191-
// Get existing open issues with Tests label
1192-
const existing = await github.paginate(github.rest.issues.listForRepo, {
1193-
owner: context.repo.owner,
1194-
repo: context.repo.repo,
1195-
state: 'open',
1196-
labels: 'Tests',
1197-
per_page: 100
1198-
});
1199-
1200-
for (const job of failedJobs) {
1201-
const jobName = job.name;
1202-
const jobUrl = job.html_url;
1203-
1204-
// Replace template variables
1205-
const vars = {
1206-
'JOB_NAME': jobName,
1207-
'RUN_LINK': jobUrl
1208-
};
1209-
1210-
let title = frontmatter.match(/title:\s*'(.*)'/)[1];
1211-
let issueBody = bodyTemplate;
1212-
for (const [key, value] of Object.entries(vars)) {
1213-
const pattern = new RegExp(`\\{\\{\\s*env\\.${key}\\s*\\}\\}`, 'g');
1214-
title = title.replace(pattern, value);
1215-
issueBody = issueBody.replace(pattern, value);
1216-
}
1217-
1218-
const existingIssue = existing.find(i => i.title === title);
1219-
1220-
if (existingIssue) {
1221-
console.log(`Issue already exists for ${jobName}: #${existingIssue.number}`);
1222-
continue;
1223-
}
1224-
1225-
const newIssue = await github.rest.issues.create({
1226-
owner: context.repo.owner,
1227-
repo: context.repo.repo,
1228-
title: title,
1229-
body: issueBody.trim(),
1230-
labels: ['Tests']
1231-
});
1232-
console.log(`Created issue #${newIssue.data.number} for ${jobName}`);
1233-
}
1173+
const { default: run } = await import(
1174+
`${process.env.GITHUB_WORKSPACE}/scripts/report-ci-failures.mjs`
1175+
);
1176+
await run({ github, context, core });
12341177
12351178
- name: Check for failures
12361179
if: cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')

dev-packages/browser-integration-tests/playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const config: PlaywrightTestConfig = {
3030
},
3131
],
3232

33-
reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list',
33+
reporter: process.env.CI ? [['list'], ['github'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list',
3434

3535
globalSetup: require.resolve('./playwright.setup.ts'),
3636
globalTeardown: require.resolve('./playwright.teardown.ts'),

dev-packages/test-utils/src/playwright-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function getPlaywrightConfig(
3737
/* In dev mode some apps are flaky, so we allow retry there... */
3838
retries: testEnv === 'development' ? 3 : 0,
3939
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
40-
reporter: process.env.CI ? [['line'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list',
40+
reporter: process.env.CI ? [['line'], ['github'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list',
4141
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
4242
use: {
4343
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */

packages/nuxt/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import baseConfig from '../../vite/vite.config';
33
export default {
44
...baseConfig,
55
test: {
6+
...baseConfig.test,
67
environment: 'jsdom',
78
setupFiles: ['./test/vitest.setup.ts'],
89
typecheck: {

scripts/report-ci-failures.mjs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* CI Failure Reporter script.
3+
*
4+
* Creates GitHub issues for tests that fail on the develop branch.
5+
* For each failed job in the workflow run, it fetches check run annotations
6+
* to identify individual failing tests, then creates one issue per failing
7+
* test using the FLAKY_CI_FAILURE_TEMPLATE.md template. Existing open issues
8+
* with matching titles are skipped to avoid duplicates.
9+
*
10+
* Intended to be called from a GitHub Actions workflow via actions/github-script:
11+
*
12+
* const { default: run } = await import(
13+
* `${process.env.GITHUB_WORKSPACE}/scripts/report-ci-failures.mjs`
14+
* );
15+
* await run({ github, context, core });
16+
*/
17+
18+
import { readFileSync } from 'node:fs';
19+
20+
export default async function run({ github, context, core }) {
21+
const { owner, repo } = context.repo;
22+
23+
// Fetch actual job details from the API to get descriptive names
24+
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
25+
owner,
26+
repo,
27+
run_id: context.runId,
28+
per_page: 100,
29+
});
30+
31+
const failedJobs = jobs.filter(job => job.conclusion === 'failure' && !job.name.includes('(optional)'));
32+
33+
if (failedJobs.length === 0) {
34+
core.info('No failed jobs found');
35+
return;
36+
}
37+
38+
// Read and parse template
39+
const template = readFileSync('.github/FLAKY_CI_FAILURE_TEMPLATE.md', 'utf8');
40+
const [, frontmatter, bodyTemplate] = template.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
41+
42+
// Get existing open issues with Tests label
43+
const existing = await github.paginate(github.rest.issues.listForRepo, {
44+
owner,
45+
repo,
46+
state: 'open',
47+
labels: 'Tests',
48+
per_page: 100,
49+
});
50+
51+
for (const job of failedJobs) {
52+
const jobName = job.name;
53+
const jobUrl = job.html_url;
54+
55+
// Fetch annotations from the check run to extract failed test names
56+
let testNames = [];
57+
try {
58+
const annotations = await github.paginate(github.rest.checks.listAnnotations, {
59+
owner,
60+
repo,
61+
check_run_id: job.id,
62+
per_page: 100,
63+
});
64+
65+
const testAnnotations = annotations.filter(a => a.annotation_level === 'failure' && a.path !== '.github');
66+
testNames = [...new Set(testAnnotations.map(a => a.title || a.path))];
67+
} catch (e) {
68+
core.info(`Could not fetch annotations for ${jobName}: ${e.message}`);
69+
}
70+
71+
// If no test names found, fall back to one issue per job
72+
if (testNames.length === 0) {
73+
testNames = ['Unknown test'];
74+
}
75+
76+
// Create one issue per failing test for proper deduplication
77+
for (const testName of testNames) {
78+
const vars = {
79+
JOB_NAME: jobName,
80+
RUN_LINK: jobUrl,
81+
TEST_NAME: testName,
82+
};
83+
84+
let title = frontmatter.match(/title:\s*'(.*)'/)[1];
85+
let issueBody = bodyTemplate;
86+
for (const [key, value] of Object.entries(vars)) {
87+
const pattern = new RegExp(`\\{\\{\\s*env\\.${key}\\s*\\}\\}`, 'g');
88+
title = title.replace(pattern, value);
89+
issueBody = issueBody.replace(pattern, value);
90+
}
91+
92+
const existingIssue = existing.find(i => i.title === title);
93+
94+
if (existingIssue) {
95+
core.info(`Issue already exists for "${testName}" in ${jobName}: #${existingIssue.number}`);
96+
continue;
97+
}
98+
99+
const newIssue = await github.rest.issues.create({
100+
owner,
101+
repo,
102+
title,
103+
body: issueBody.trim(),
104+
labels: ['Tests'],
105+
});
106+
core.info(`Created issue #${newIssue.data.number} for "${testName}" in ${jobName}`);
107+
}
108+
}
109+
}

vite/vite.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export default defineConfig({
1919
'vite.config.*',
2020
],
2121
},
22-
reporters: process.env.CI ? ['default', ['junit', { classnameTemplate: '{filepath}' }]] : ['default'],
22+
reporters: process.env.CI
23+
? ['default', 'github-actions', ['junit', { classnameTemplate: '{filepath}' }]]
24+
: ['default'],
2325
outputFile: {
2426
junit: 'vitest.junit.xml',
2527
},

0 commit comments

Comments
 (0)