Flaky tests waste time, erode confidence, and make debugging a nightmare — especially when running in parallel across multiple CI machines.
Cypress Cloud provides fantastic flake detection and historical insights, but what if you don’t have access to it? You’re not out of luck.
In this guide, I’ll show you how to build your own automated flake alerting system that runs weekly, requires no manual effort, and integrates with your existing workflow.
By the end, you’ll have a setup that:
- Tracks flake across different environments
- Detects trends over time
- Automatically alerts your team when thresholds are exceeded
Let’s dive in.
Prerequisite: Merge Report Infrastructure
This tutorial builds upon an earlier post How to Create a Custom Merge Report in Cypress. If you haven’t implemented that yet, start there — we’ll extend that implementation to save, merge and analyze flake data.
1. Save the Flake Results
Once your custom merge-report script is running, let’s expand it to track flake history over time
Add this at the bottom of your merge script:
// Flake Detection
const flakeHistoryPath = path.join(resultsDir, 'flake-history.json')
const flakeHistory = {}
// Optional: Differentiate by environment
const env = mergedResults.baseUrl.includes('localhost') ? 'local' : 'dev'
delete mergedResults.baseUrl
for (const { test } of flakyTests) {
flakeHistory[test] = {
env,
lastSeen: new Date().toISOString(),
}
// Save file
fs.writeFileSync(flakeHistoryPath, JSON.stringify(flakeHistory, null, 2))
console.log(`Flake history created at ${flakeHistoryPath}`)
💡 Tip: Tracking the env (like dev or local) helps you isolate which environments are producing flaky results.
Now each test run will produce a flake-history.json
file.
Let’s persist it for future aggregation.
2. Add a Weekly Aggregator Pipeline
We’ll now persist the flake history files and process them weekly.
2.1 Persist Flake Files
In your merge workflow, upload the artifact after the script runs. The following example uses Github Actions but any CI/CD tool works.
- name: Upload Cypress Flake Results
uses: actions/upload-artifact@v4
with:
name: flake-history-${{ github.run_id }}
path: cypress/results/flake-history.json
retention-days: 7
2.2 Weekly Aggregation Workflow
Next, set up a new pipeline workflow that runs weekly. It will fetch all flake files from our recent runs and prepare them for merging
name: Cypress - Flake Aggregator
on:
schedule:
- cron: '0 9 * * MON' # 3am MDT every Monday, tweak as desired
workflow_dispatch:
jobs:
aggregate-flake-files:
runs-on: your-runner # Your runner
steps:
- name: Checkout
uses: actions/checkout@v4
# 1. Get Workflow Run IDs from past 7 days
- name: Get Recent Test Workflow Runs
id: get-cypress-runs
uses: actions/github-script@v7
with:
script: |
const workflowNames = ['Cypress Tests'] // Your Cypress GH Workflow names
const sinceDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // 7 days ago
const allRunIds = []
const workflowsResp = await github.rest.actions.listRepoWorkflows({
owner: context.repo.owner,
repo: context.repo.repo,
})
for (const name of workflowNames) {
const wf = workflowsResp.data.workflows.find(wf => wf.name === name)
if (!wf) continue
let page = 1
let keepFetching = true
while (keepFetching) {
const runsResp = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: wf.id,
per_page: 100,
page,
})
// Fetch all workflows within the last week and persist run ids
const recentRuns = runsResp.data.workflow_runs.filter(run =>
new Date(run.created_at) >= sinceDate
)
recentRuns.forEach(run => allRunIds.push(run.id))
// Stop if there are no more recent runs or we hit end of pages
if (
recentRuns.length < runsResp.data.workflow_runs.length ||
runsResp.data.workflow_runs.length === 0
) {
keepFetching = false
} else {
page++
}
}
}
return allRunIds
result-encoding: string
# 2. Download Artifacts
- name: Download Flake Artifacts
run: |
mkdir -p cypress/flakes // Wherever you stored the results
echo '[${{ steps.get-cypress-runs.outputs.result }}]' | jq -r '.[]' | while read RUN_ID; do
artifact_name="flake-history-$RUN_ID"
echo "Attempting to download $artifact_name from run $RUN_ID"
if gh run download $RUN_ID -n $artifact_name -D cypress/flakes/; then
// Rename each file so it's unique
mv "cypress/flakes/flake-history.json" "cypress/flakes/flake-history-$RUN_ID.json"
else
echo "Missing: $artifact_name"
fi
done
# 3. Merge All Flake Files
- name: Merge Flake History Files
run: node cypress/merge-flake-history.js
# 4. Optionally Upload Aggregated Report
- name: Upload Aggregated Report
uses: actions/upload-artifact@v4
with:
name: aggregated-flake-history
path: cypress/merged-flake-history.json
retention-days: 7
Now, every Monday this job collects all flake history files from the past week and merges them.
Here’s the complete process at a glance, from Cypress test runs to automated alerts:
3. Merge the Flake Results
With the workflow in place, create the new merge-flake-history.js
script. This script will loop through all the flake files from the past week and aggregate counts by test name and environment.
/* eslint-disable no-undef */
const fs = require('fs')
const path = require('path')
const mergedFlakeHistoryPath = path.join(__dirname, 'merged-flake-history.json')
const mergeFlakeHistories = async () => {
const flakesDir = path.join(__dirname, 'flakes')
const flakeFiles = fs.readdirSync(flakesDir).filter((f) => f.endsWith('.json'))
console.log(`Found ${flakeFiles.length} flake files to analyze`)
const merged = {}
// Loop through all flake files
for (const file of flakeFiles) {
const contents = fs.readFileSync(path.join(flakesDir, file), 'utf-8')
const flakeData = JSON.parse(contents)
// For each flaky test, add to a new merged object
for (const testName in flakeData) {
const { env, lastSeen } = flakeData[testName]
if (!merged[testName]) {
merged[testName] = {}
}
if (!merged[testName][env]) {
merged[testName][env] = {
count: 1,
lastSeen,
}
} else {
// Count amount of times the test has flaked across all files
merged[testName][env].count += 1
const existingDate = new Date(merged[testName][env].lastSeen)
const newDate = new Date(lastSeen)
// Use the earliest lastSeen timestamp
if (newDate < existingDate) {
merged[testName][env].lastSeen = lastSeen
}
}
}
}
// Persist merge flake report
fs.writeFileSync(mergedFlakeHistoryPath, JSON.stringify(merged, null, 2))
console.log(`Merged flake history written to ${mergedFlakeHistoryPath}`)
}
mergeFlakeHistories()
Example output:
{
"Checkout Flow - should display cart": {
"local": { "count": 2, "lastSeen": "2025-08-05T14:58:03.908Z" }
},
"Login - valid credentials": {
"local": { "count": 10, "lastSeen": "2025-08-05T14:58:03.913Z" },
"dev": { "count": 4, "lastSeen": "2025-08-07T12:40:06.156Z" }
}
}
4. Alert When Thresholds are Exceeded
Finally, with our merged report, we can trigger alerts when flake counts pass defined thresholds.
Here’s an example using an integration using Linear.
// Define thresholds based on env
const LOCAL_FLAKE_THRESHOLD = 7
const DEV_FLAKE_THRESHOLD = 3
// Any integration secrets
const LINEAR_API_KEY = process.env.LINEAR_API_KEY
const LINEAR_TEAM_ID = process.env.LINEAR_TEAM_ID
// Linear integration
const createLinearTicket = async (testName, count, env, lastSeen) => {
const body = {
query: `
mutation {
issueCreate(input: {
title: "Cypress Flaky Test: ${testName}",
description: "This test flaked ${count} times in the last week on ${env}. First observed: ${lastSeen}",
teamId: "${LINEAR_TEAM_ID}"
}) {
success
}
}
`,
}
try {
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: LINEAR_API_KEY,
},
body: JSON.stringify(body),
})
const json = await response.json()
if (json.data.issueCreate.success) {
console.log(`Linear ticket created for "${testName}"`)
} else {
console.error(`Failed to create Linear ticket for "${testName}"`, json)
}
} catch (err) {
console.error(`Error creating Linear ticket for "${testName}"`, err)
}
}
// After merging results in mergeFlakeHistories
for (const testName in merged) {
for (const env in merged[testName]) {
const { count, lastSeen } = merged[testName][env]
// Optionally evaluate threshold based on env
const threshold = env === 'local' ? LOCAL_FLAKE_THRESHOLD : DEV_FLAKE_THRESHOLD
// Create Linear ticket if flake detected
if (count >= threshold) {
console.log(`Flake threshold exceeded: ${testName} (${count} on ${env})`)
await createLinearTicket(testName, count, env, lastSeen)
}
}
}
💡 Tip: Swap out Linear for Jira, Slack, or any other alerting tool your team uses.
A simple, example Linear ticket that’s automatically created might look like this:
Conclusion
With this setup, automatically every week your team gets actionable alerts about the flakiest tests — without anyone manually digging through logs.
You’ll:
- Automatically collect and merge flake data
- Detect patterns per environment
- Alert only when thresholds are breached
- Keep your test suite healthier over time
You don’t need a full observability platform to track flake — just a clever CI workflow and a few scripts. For teams needing more depth, Cypress Cloud offers free trials.
Happy testing!
Top comments (0)