diff --git a/src/pages/AnalyticsPage/components/TestResultsChart/TestResultsChart.tsx b/src/pages/AnalyticsPage/components/TestResultsChart/TestResultsChart.tsx new file mode 100644 index 0000000000..03c044f45f --- /dev/null +++ b/src/pages/AnalyticsPage/components/TestResultsChart/TestResultsChart.tsx @@ -0,0 +1,274 @@ +import { cn } from 'shared/utils/cn' + +interface TestResult { + id: string + name: string + duration: number + status: 'passed' | 'failed' | 'skipped' + timestamp: number + branch: string +} + +interface ChartDataPoint { + date: string + passed: number + failed: number + skipped: number + totalDuration: number +} + +interface TestResultsChartProps { + results: TestResult[] +} + +export function TestResultsChart({ results }: TestResultsChartProps) { + if (!results || results.length === 0) { + return null + } + + const chartDataMap = results.reduce( + (acc: Record, result) => { + if ( + !result.timestamp || + !Number.isFinite(result.timestamp) || + result.timestamp < 0 + ) { + return acc + } + + const dateObj = new Date(result.timestamp) + if (isNaN(dateObj.getTime())) { + return acc + } + + if ( + !result.status || + !['passed', 'failed', 'skipped'].includes(result.status) + ) { + return acc + } + + const date = dateObj.toLocaleDateString() + + if (!acc[date]) { + acc[date] = { + date, + passed: 0, + failed: 0, + skipped: 0, + totalDuration: 0, + } + } + + const dataPoint = acc[date] + if (dataPoint) { + dataPoint[result.status]++ + const duration = + Number.isFinite(result.duration) && result.duration >= 0 + ? result.duration + : 0 + dataPoint.totalDuration += duration + } + + return acc + }, + {} + ) + + const chartData = Object.values(chartDataMap) + + const slowestTests = results + .filter((result) => result.status !== 'skipped') + .filter( + (result) => Number.isFinite(result.duration) && result.duration >= 0 + ) + .sort((a, b) => b.duration - a.duration) + .slice(0, 10) + + const branchStats = results.reduce( + ( + acc: Record< + string, + { total: number; failed: number; avgDuration: number } + >, + result + ) => { + if (!result.branch) { + return acc + } + + if (!acc[result.branch]) { + acc[result.branch] = { total: 0, failed: 0, avgDuration: 0 } + } + const branchData = acc[result.branch] + if (branchData) { + branchData.total++ + if (result.status === 'failed') { + branchData.failed++ + } + + const duration = + Number.isFinite(result.duration) && result.duration >= 0 + ? result.duration + : 0 + branchData.avgDuration = + (branchData.avgDuration * (branchData.total - 1) + duration) / + branchData.total + } + return acc + }, + {} + ) + + const testsByName = results.reduce( + (acc: Record, result) => { + if (!result.name) { + return acc + } + + if (!acc[result.name]) { + acc[result.name] = [] + } + acc[result.name]?.push(result) + return acc + }, + {} + ) + + const flakinessScores: Record = {} + Object.entries(testsByName).forEach(([testName, testResults]) => { + if (testResults.length === 0) { + return + } + + const sortedResults = [...testResults].sort( + (a, b) => a.timestamp - b.timestamp + ) + + let statusChanges = 0 + for (let i = 1; i < sortedResults.length; i++) { + const current = sortedResults[i] + const previous = sortedResults[i - 1] + if (current && previous && current.status !== previous.status) { + statusChanges++ + } + } + flakinessScores[testName] = (statusChanges / sortedResults.length) * 100 + }) + + const flakyTests = Object.entries(flakinessScores) + .filter(([, score]) => score > 20) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + + return ( +
+
+

Test Results Over Time

+
+ {chartData.map((point) => ( +
+ {point.date} +
+
+
+ {point.passed} +
+
+
+ {point.failed} +
+
+
+ {point.skipped} +
+
+ + {Number.isFinite(point.totalDuration) + ? (point.totalDuration / 1000).toFixed(2) + : '0.00'} + s + +
+ ))} +
+
+ +
+

Slowest Tests

+
+ {slowestTests.map((test) => ( +
+ {test.name} + + {Number.isFinite(test.duration) + ? (test.duration / 1000).toFixed(2) + : '0.00'} + s + +
+ ))} +
+
+ + {flakyTests.length > 0 && ( +
+

Flaky Tests

+
+ {flakyTests.map(([name, score]) => ( +
+ {name} + + {score.toFixed(1)}% flaky + +
+ ))} +
+
+ )} + +
+

Branch Statistics

+
+ {Object.entries(branchStats).map(([branch, stats]) => ( +
0 + ? 'border-ds-primary-red bg-ds-pink-default' + : 'border-ds-gray-tertiary bg-white' + )} + > +

{branch}

+
+
Total: {stats.total}
+
+ Failed: {stats.failed} +
+
+ Avg:{' '} + {Number.isFinite(stats.avgDuration) + ? stats.avgDuration.toFixed(0) + : '0'} + ms +
+
+
+ ))} +
+
+
+ ) +} + +export default TestResultsChart