panoptical
Version:
A modern testing tool that makes Playwright easy to use with simple YAML syntax
1,324 lines (1,128 loc) • 46.2 kB
JavaScript
import express from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { generateTestDetailsHTML } from './test-details.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export function createReportServer(port = 3000) {
const app = express();
enforceTestRunLimits();
app.use('/static', express.static(path.join(__dirname, 'static')));
app.get('/panoptical-logo.png', (req, res) => {
res.sendFile(path.join(__dirname, 'panoptical-logo.png'));
});
app.get('/', (req, res) => {
const html = generateDashboardHTML();
res.send(html);
});
app.get('/api/test-data', (req, res) => {
try {
const runsData = fs.existsSync('.panoptical/runs.json')
? JSON.parse(fs.readFileSync('.panoptical/runs.json', 'utf8'))
: {};
const flakesData = fs.existsSync('.panoptical/flakes.json')
? JSON.parse(fs.readFileSync('.panoptical/flakes.json', 'utf8'))
: {};
const limitedRunsData = limitTestRuns(runsData, 50);
const chartData = getChartData(limitedRunsData, 20);
// Add relative time data for charts
const chartDataWithTime = {};
for (const [testName, runs] of Object.entries(chartData)) {
chartDataWithTime[testName] = runs.map((run, index) => ({
...run,
chartIndex: index + 1, // Simple 1,2,3... numbering for charts
relativeTime: getRelativeTime(run.ts)
}));
}
res.json({
runs: limitedRunsData,
chartData: chartDataWithTime, // Enhanced data for charts
flakes: flakesData,
timestamp: Date.now()
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/test/:testName', (req, res) => {
try {
const { testName } = req.params;
const runsData = fs.existsSync('.panoptical/runs.json')
? JSON.parse(fs.readFileSync('.panoptical/runs.json', 'utf8'))
: {};
const limitedRunsData = limitTestRuns(runsData, 50);
const testRuns = limitedRunsData[testName] || [];
const meta = readTestMeta(testName);
res.json({
testName,
runs: testRuns,
summary: generateTestSummary(testRuns),
meta
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/test/:testName', (req, res) => {
try {
const { testName } = req.params;
const runsData = fs.existsSync('.panoptical/runs.json')
? JSON.parse(fs.readFileSync('.panoptical/runs.json', 'utf8'))
: {};
// Auto-limit test runs to 50 for storage/performance
const limitedRunsData = limitTestRuns(runsData, 50);
const testRuns = limitedRunsData[testName] || [];
// Add relative time to all runs for the history table
const runsWithTime = testRuns.map(run => ({
...run,
relativeTime: getRelativeTime(run.ts)
}));
// For charts, use only the last 20 runs for better visualization
const chartRuns = runsWithTime.slice(-20).map((run, index) => ({
...run,
chartIndex: index + 1, // Simple 1,2,3... numbering for charts
}));
const meta = readTestMeta(testName);
const testData = {
testName,
runs: runsWithTime, // Full data with relative time for summary and details
chartRuns: chartRuns, // Limited data for charts with enhanced info
summary: generateTestSummary(testRuns),
meta
};
const html = generateTestDetailsHTML(testName, testData);
res.send(html);
} catch (error) {
res.status(500).send(`<h1>Error</h1><p>${error.message}</p>`);
}
});
return app;
}
function generateTestSummary(runs) {
if (runs.length === 0) return { total: 0, passed: 0, failed: 0, passRate: 0 };
const total = runs.length;
const passed = runs.filter(r => r.status === 'pass').length;
const failed = total - passed;
const passRate = Math.round((passed / total) * 100);
return { total, passed, failed, passRate };
}
// Automatically limit test runs to keep only the most recent ones (enforced limit)
function limitTestRuns(runsData, maxRuns = 50) {
const limitedData = {};
let totalRunsRemoved = 0;
for (const [testName, runs] of Object.entries(runsData)) {
if (runs.length > maxRuns) {
// Keep only the most recent runs (newest first, so slice from end)
limitedData[testName] = runs.slice(-maxRuns);
totalRunsRemoved += runs.length - maxRuns;
} else {
limitedData[testName] = runs;
}
}
// Always enforce the limit - this is default behavior
if (totalRunsRemoved > 0) {
console.log(`Auto-enforced limit: Kept last ${maxRuns} runs per test, removed ${totalRunsRemoved} old runs`);
}
return limitedData;
}
// Get limited test runs for charts (enforced limit for better visualization)
function getChartData(runsData, maxChartRuns = 20) {
const chartData = {};
for (const [testName, runs] of Object.entries(runsData)) {
// Charts automatically show only the last 20 runs for better readability
chartData[testName] = runs.slice(-maxChartRuns);
}
return chartData;
}
// Convert timestamp to human-readable relative time
function getRelativeTime(timestamp) {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const months = Math.floor(diff / (1000 * 60 * 60 * 24 * 30));
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes} min${minutes === 1 ? '' : 's'} ago`;
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`;
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`;
// Fallback to date for runs older than 30 days
const date = new Date(timestamp);
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${monthNames[date.getMonth()]} ${date.getDate()}`;
}
// Automatically enforce test run limits and save the limited data back to files
function enforceTestRunLimits() {
try {
if (!fs.existsSync('.panoptical/runs.json')) {
return; // No data to enforce limits on
}
const runsData = JSON.parse(fs.readFileSync('.panoptical/runs.json', 'utf8'));
const flakesData = fs.existsSync('.panoptical/flakes.json')
? JSON.parse(fs.readFileSync('.panoptical/flakes.json', 'utf8'))
: {};
let totalRunsRemoved = 0;
let testsTruncated = 0;
let dataChanged = false;
// Enforce 50-run limit per test
for (const [testName, runs] of Object.entries(runsData)) {
if (runs.length > 50) {
const runsToRemove = runs.length - 50;
runsData[testName] = runs.slice(-50); // Keep newest 50 runs
totalRunsRemoved += runsToRemove;
testsTruncated++;
dataChanged = true;
// Also update flakes data if it exists
if (flakesData[testName] && flakesData[testName].runs) {
const remainingRunTimestamps = new Set(runsData[testName].map(r => r.ts));
flakesData[testName].runs = flakesData[testName].runs.filter(r =>
remainingRunTimestamps.has(r.ts)
);
}
}
}
if (dataChanged) {
// Save the limited data back to files
fs.writeFileSync('.panoptical/runs.json', JSON.stringify(runsData, null, 2));
fs.writeFileSync('.panoptical/flakes.json', JSON.stringify(flakesData, null, 2));
console.log(`Auto-enforced limits: Limited ${testsTruncated} tests to 50 runs, removed ${totalRunsRemoved} old runs`);
}
} catch (error) {
console.warn(`Auto-enforcement failed: ${error.message}`);
}
}
// Read test metadata (name, description, file path) from YAML in tests/ directory (recursively searches subdirectories)
function readTestMeta(testName) {
try {
const base = testName.replace(/\.(yaml|yml)$/i, '');
const testsDir = 'tests';
if (!fs.existsSync(testsDir)) {
return { file: null, name: testName, description: '' };
}
// Recursively search for the test file
function findTestFile(dir) {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
// Recursively search subdirectories
const found = findTestFile(fullPath);
if (found) return found;
} else if (item === base + '.yaml' || item === base + '.yml') {
// Found the test file
return fullPath;
}
}
return null;
}
const foundPath = findTestFile(testsDir);
if (!foundPath) {
return { file: null, name: testName, description: '' };
}
const content = fs.readFileSync(foundPath, 'utf8');
const nameMatch = content.match(/^\s*test:\s*(.+)\s*$/m);
const descMatch = content.match(/^\s*description:\s*(.+)\s*$/m);
const name = nameMatch ? nameMatch[1].trim() : testName;
const description = descMatch ? descMatch[1].trim() : '';
return { file: foundPath, name, description };
} catch (e) {
return { file: null, name: testName, description: '' };
}
}
function generateDashboardHTML() {
return `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Panoptical Test Reports Server</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #0f172a;
min-height: 100vh;
color: #e2e8f0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 15px;
}
.header {
text-align: center;
margin-bottom: 25px;
color: white;
}
.header h1 {
font-size: 2.2rem;
margin-bottom: 8px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1rem;
opacity: 0.9;
}
.header-actions {
margin-top: 15px;
}
.restart-button {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
}
.restart-button:hover {
background: linear-gradient(135deg, #2563eb, #1e40af);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
}
.restart-button:active {
transform: translateY(0);
}
.restart-button i {
font-size: 0.85rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 25px;
}
.stat-card {
background: #1e293b;
border-radius: 12px;
padding: 18px;
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
text-align: center;
transition: transform 0.3s ease, box-shadow 0.3s ease;
border: 1px solid #334155;
cursor: pointer;
}
.stat-card:hover {
transform: none;
box-shadow: 0 15px 40px rgba(0,0,0,0.15);
}
.stat-card.success {
border-left: 5px solid #10b981;
}
.stat-card.danger {
border-left: 5px solid #ef4444;
}
.stat-card.warning {
border-left: 5px solid #f59e0b;
}
.stat-card.info {
border-left: 5px solid #3b82f6;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
margin-bottom: 8px;
}
.stat-label {
font-size: 0.875rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-label i {
margin-left: 6px;
color: #94a3b8;
cursor: help;
font-size: 0.85rem;
}
.success .stat-number { color: #10b981; }
.danger .stat-number { color: #ef4444; }
.warning .stat-number { color: #f59e0b; }
.info .stat-number { color: #3b82f6; }
.charts-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 25px;
}
.stat-help {
text-align: center;
font-size: 0.85rem;
color: #94a3b8;
margin-top: -8px;
margin-bottom: 18px;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: #1e293b;
margin: 5% auto;
padding: 20px;
border: 1px solid #334155;
border-radius: 12px;
width: 80%;
max-width: 600px;
max-height: 70vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #334155;
}
.modal-title {
font-size: 1.3rem;
font-weight: 600;
color: #e2e8f0;
}
.close {
color: #94a3b8;
font-size: 28px;
font-weight: bold;
cursor: pointer;
line-height: 1;
}
.close:hover {
color: #e2e8f0;
}
.test-list {
list-style: none;
padding: 0;
}
.test-list-item {
padding: 12px;
margin: 8px 0;
background: #334155;
border: 1px solid #475569;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.test-list-item:hover {
background: #475569;
border-color: #3b82f6;
}
.test-list-item .test-name {
font-weight: 600;
color: #e2e8f0;
margin-bottom: 4px;
}
.test-list-item .test-status {
font-size: 0.875rem;
color: #94a3b8;
}
.chart-container {
background: #1e293b;
border-radius: 12px;
padding: 20px;
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
height: 280px;
border: 1px solid #334155;
display: flex;
flex-direction: column;
}
.chart-container canvas {
flex: 1;
max-height: 220px;
}
.chart-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 8px;
color: #e2e8f0;
text-align: center;
}
.chart-note {
font-size: 0.8rem;
color: #94a3b8;
text-align: center;
margin-bottom: 10px;
font-style: italic;
}
.tests-section {
background: #1e293b;
border-radius: 12px;
padding: 20px;
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
border: 1px solid #334155;
}
.section-title {
font-size: 1.3rem;
font-weight: 600;
margin-bottom: 15px;
color: #e2e8f0;
display: flex;
align-items: center;
gap: 10px;
}
.test-grid {
display: grid;
gap: 12px;
}
.test-card {
background: #334155;
border: 1px solid #475569;
border-radius: 8px;
padding: 15px;
transition: all 0.3s ease;
cursor: pointer;
}
.test-card:hover {
border-color: #3b82f6;
box-shadow: 0 5px 15px rgba(59, 130, 246, 0.2);
background: #475569;
}
.search-container {
margin-bottom: 20px;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
background: #334155;
border: 1px solid #475569;
border-radius: 8px;
padding: 0 15px;
transition: all 0.3s ease;
}
.search-input-wrapper:focus-within {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-icon {
color: #94a3b8;
margin-right: 12px;
font-size: 0.9rem;
}
.search-input {
flex: 1;
background: transparent;
border: none;
color: #e2e8f0;
font-size: 1rem;
padding: 12px 0;
outline: none;
}
.search-input::placeholder {
color: #94a3b8;
}
.search-stats {
color: #94a3b8;
font-size: 0.85rem;
margin-left: 15px;
white-space: nowrap;
}
.test-card.hidden {
display: none;
}
.test-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.test-name {
font-weight: 600;
color: #e2e8f0;
}
.test-status {
padding: 4px 12px;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
.status-pass { background: #065f46; color: #d1fae5; }
.status-fail { background: #991b1b; color: #fee2e2; }
.status-flaky { background: #92400e; color: #fef3c7; }
.test-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 12px;
}
.metric {
text-align: center;
}
.metric-value {
font-size: 1.1rem;
font-weight: 600;
color: #e2e8f0;
}
.metric-label {
font-size: 0.8rem;
color: #94a3b8;
margin-top: 4px;
}
.test-timeline {
height: 35px;
background: #475569;
border-radius: 6px;
overflow: hidden;
position: relative;
display: flex;
}
.timeline-bar {
height: 100%;
transition: width 0.3s ease;
min-width: 1px;
}
.timeline-pass { background: #10b981; }
.timeline-fail { background: #ef4444; }
.loading {
text-align: center;
padding: 40px 20px;
color: #94a3b8;
}
.spinner {
border: 3px solid #475569;
border-top: 3px solid #3b82f6;
border-radius: 50%;
width: 35px;
height: 35px;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.charts-section {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.header h1 {
font-size: 2rem;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="/panoptical-logo.png" alt="Panoptical Logo" style="height: 45px; margin-bottom: 15px;">
<h1>Test Reports Server</h1>
<p>Live Testing Analytics & Insights</p>
<div class="header-actions">
<button id="restart-btn" class="restart-button" onclick="refreshData()" title="Refresh page and fetch latest test data">
<i class="fas fa-sync-alt"></i>
Refresh Data
</button>
</div>
</div>
<div id="loading" class="loading">
<div class="spinner"></div>
<p>Loading test data...</p>
</div>
<div id="dashboard" style="display: none;">
<div class="stats-grid">
<div class="stat-card info" onclick="showTestList('total', 'All Tests')">
<div class="stat-number" id="total-tests">-</div>
<div class="stat-label">Total Tests <i class="fas fa-info-circle" title="Unique tests found in .panoptical/runs.json (one per YAML test). Counted per test, not per run."></i></div>
</div>
<div class="stat-card success" onclick="showTestList('stable', 'Stable Tests')">
<div class="stat-number" id="passed-tests">-</div>
<div class="stat-label">Passed <i class="fas fa-info-circle" title="Tests currently Stable based on recent history (e.g., last 3 or 5 runs all passed)."></i></div>
</div>
<div class="stat-card danger" onclick="showTestList('failing', 'Failing Tests')">
<div class="stat-number" id="failed-tests">-</div>
<div class="stat-label">Failed <i class="fas fa-info-circle" title="Tests currently Failing based on recent history (e.g., recent runs mostly failed)."></i></div>
</div>
<div class="stat-card warning" onclick="showTestList('flaky', 'Flaky Tests')">
<div class="stat-number" id="flaky-tests">-</div>
<div class="stat-label">Flaky <i class="fas fa-info-circle" title="Tests with mixed recent results (some runs passed, some failed)."></i></div>
</div>
</div>
<div class="stat-help">Counts are per test (not per run). Status reflects recent run history.</div>
<!-- Test List Modal -->
<div id="testModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="modalTitle">Test List</div>
<span class="close" onclick="closeModal()">×</span>
</div>
<ul id="testList" class="test-list">
<!-- Test list items will be populated here -->
</ul>
</div>
</div>
<div class="charts-section">
<div class="chart-container">
<div class="chart-title">Test Results Overview</div>
<canvas id="resultsChart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">Top 20 Longest-Running Tests</div>
<div class="chart-note">Showing tests with longest execution times</div>
<canvas id="durationChart"></canvas>
</div>
</div>
<div class="tests-section">
<div class="section-title">
<i class="fas fa-list"></i>
Test Details
</div>
<div class="search-container">
<div class="search-input-wrapper">
<i class="fas fa-search search-icon"></i>
<input type="text" id="test-search" placeholder="Search tests by name..." class="search-input">
<div class="search-stats">
<span id="search-results-count">-</span> of <span id="total-tests-count">-</span> tests
</div>
</div>
</div>
<div id="test-grid" class="test-grid">
<!-- Test cards will be populated here -->
</div>
</div>
</div>
</div>
<script>
let testData = null;
let resultsChart = null;
let durationChart = null;
// Format duration from milliseconds to human-readable format
function formatDuration(ms) {
if (ms < 1000) {
return ms + 'ms';
}
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
const remainingMinutes = minutes % 60;
const remainingSeconds = seconds % 60;
return hours + 'h ' + remainingMinutes + 'm ' + remainingSeconds + 's';
} else if (minutes > 0) {
const remainingSeconds = seconds % 60;
return minutes + 'm ' + remainingSeconds + 's';
} else {
return seconds + 's';
}
}
// Determine test stability based on recent run patterns
function determineTestStatus(testRuns) {
if (!Array.isArray(testRuns) || testRuns.length === 0) {
return { key: 'stable', className: 'status-pass', text: 'Stable' };
}
const sortedByTime = [...testRuns].sort((a, b) => {
const at = typeof a.ts === 'number' ? a.ts : 0;
const bt = typeof b.ts === 'number' ? b.ts : 0;
return at - bt;
});
const lastThree = sortedByTime.slice(-3);
const lastFive = sortedByTime.slice(-5);
// Check recent patterns
const lastThreeAllPass = lastThree.length >= 3 && lastThree.every(r => r.status === 'pass');
const lastThreeAllFail = lastThree.length >= 3 && lastThree.every(r => r.status === 'fail');
const lastFiveAllPass = lastFive.length >= 5 && lastFive.every(r => r.status === 'pass');
// If last 3+ are all passes, consider stable
if (lastThreeAllPass || lastFiveAllPass) {
return { key: 'stable', className: 'status-pass', text: 'Stable' };
}
// If last 3+ are all fails, consider failing
if (lastThreeAllFail) {
return { key: 'failing', className: 'status-fail', text: 'Failing' };
}
// Check if there are recent failures (last 3 runs)
const recentFailures = lastThree.filter(r => r.status === 'fail').length;
const recentPasses = lastThree.filter(r => r.status === 'pass').length;
// If recent failures outnumber recent passes, mark as failing
if (recentFailures > recentPasses) {
return { key: 'failing', className: 'status-fail', text: 'Failing' };
}
// If mixed recent results, mark as flaky
if (recentFailures > 0 && recentPasses > 0) {
return { key: 'flaky', className: 'status-flaky', text: 'Flaky' };
}
// Default to stable if no clear pattern
return { key: 'stable', className: 'status-pass', text: 'Stable' };
}
async function loadTestData() {
try {
const response = await fetch('/api/test-data');
testData = await response.json();
renderDashboard();
} catch (error) {
console.error('Failed to load test data:', error);
document.getElementById('loading').innerHTML = '<p style="color: #ef4444;">Failed to load test data</p>';
}
}
function renderDashboard() {
document.getElementById('loading').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
renderStats();
renderCharts();
renderTestGrid();
// Setup search functionality after dashboard is rendered
setupSearch();
}
function renderStats() {
const { runs, flakes } = testData;
const testNames = Object.keys(runs);
let totalTests = testNames.length;
let passedTests = 0;
let failedTests = 0;
let flakyTests = 0;
testNames.forEach(testName => {
const testRuns = runs[testName];
const status = determineTestStatus(testRuns).key;
if (status === 'stable') passedTests++;
else if (status === 'failing') failedTests++;
else flakyTests++;
});
document.getElementById('total-tests').textContent = totalTests;
document.getElementById('passed-tests').textContent = passedTests;
document.getElementById('failed-tests').textContent = failedTests;
document.getElementById('flaky-tests').textContent = flakyTests;
}
function renderCharts() {
renderResultsChart();
renderDurationChart();
}
function renderResultsChart() {
const ctx = document.getElementById('resultsChart').getContext('2d');
if (resultsChart) {
resultsChart.destroy();
}
const { runs } = testData;
const testNames = Object.keys(runs);
let passCount = 0;
let failCount = 0;
let flakyCount = 0;
testNames.forEach(testName => {
const status = determineTestStatus(runs[testName]).key;
if (status === 'stable') passCount++;
else if (status === 'failing') failCount++;
else flakyCount++;
});
resultsChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Passed', 'Failed', 'Flaky'],
datasets: [{
data: [passCount, failCount, flakyCount],
backgroundColor: ['#10b981', '#ef4444', '#f59e0b'],
borderWidth: 2,
borderColor: '#1e293b'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 15,
usePointStyle: true,
font: {
size: 11,
color: '#e2e8f0'
}
}
}
}
}
});
}
function renderDurationChart() {
const ctx = document.getElementById('durationChart');
if (!ctx) return;
if (durationChart) {
durationChart.destroy();
}
const { runs } = testData;
const testNames = Object.keys(runs);
// Get the most recent run for each test
const recentRuns = [];
testNames.forEach(testName => {
const testRuns = runs[testName];
if (testRuns.length > 0) {
const lastRun = testRuns[testRuns.length - 1];
recentRuns.push({
testName,
duration: lastRun.duration,
status: lastRun.status,
timestamp: lastRun.ts
});
}
});
// Sort by duration (longest first) and take top 20
recentRuns.sort((a, b) => b.duration - a.duration);
const top20LongestRuns = recentRuns.slice(0, 20);
try {
durationChart = new Chart(ctx, {
type: 'bar',
data: {
labels: top20LongestRuns.map(r => r.testName.length > 15 ? r.testName.substring(0, 15) + '...' : r.testName),
datasets: [{
label: 'Duration',
data: top20LongestRuns.map(r => r.duration),
backgroundColor: top20LongestRuns.map(r =>
r.status === 'pass' ? 'rgba(16, 185, 68, 0.8)' : 'rgba(239, 68, 68, 0.8)'
),
borderColor: top20LongestRuns.map(r =>
r.status === 'pass' ? '#10b981' : '#ef4444'
),
borderWidth: 1,
borderRadius: 4,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
ticks: {
font: {
size: 10,
color: '#94a3b8'
}
},
grid: {
color: '#334155'
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: 'Duration',
font: {
size: 11,
color: '#e2e8f0'
}
},
ticks: {
font: {
size: 10,
color: '#94a3b8'
},
callback: function(value) {
return formatDuration(value);
}
},
grid: {
color: '#334155'
}
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.parsed.y;
return 'Duration: ' + formatDuration(value);
}
}
}
}
}
});
} catch (error) {
console.error('Failed to render duration chart:', error);
durationChart = null;
}
}
function renderTestGrid() {
const { runs, flakes } = testData;
const testNames = Object.keys(runs);
const testGrid = document.getElementById('test-grid');
testGrid.innerHTML = '';
testNames.forEach(testName => {
const testRuns = runs[testName];
const flakeData = flakes[testName];
const statusInfo = determineTestStatus(testRuns);
const statusClass = statusInfo.className;
const statusText = statusInfo.text;
const passRate = flakeData ? flakeData.passRate :
(hasPasses && !hasFails ? 100 : 0);
const avgDuration = testRuns.length > 0 ?
Math.round(testRuns.reduce((sum, r) => sum + r.duration, 0) / testRuns.length) : 0;
const testCard = document.createElement('div');
testCard.className = 'test-card';
testCard.onclick = () => showTestDetails(testName);
testCard.setAttribute('data-test-name', testName.toLowerCase());
testCard.innerHTML =
'<div class="test-header">' +
'<div class="test-name">' + testName + '</div>' +
'<div class="test-status ' + statusClass + '">' + statusText + '</div>' +
'</div>' +
'<div class="test-metrics">' +
'<div class="metric">' +
'<div class="metric-value">' + testRuns.length + '</div>' +
'<div class="metric-label">Total Runs</div>' +
'</div>' +
'<div class="metric">' +
'<div class="metric-value">' + passRate + '%</div>' +
'<div class="metric-label">Pass Rate</div>' +
'</div>' +
'<div class="metric">' +
'<div class="metric-value">' + formatDuration(avgDuration) + '</div>' +
'<div class="metric-label">Avg Duration</div>' +
'</div>' +
'</div>' +
'<div class="test-timeline">' +
generateTimeline(testRuns) +
'</div>';
testGrid.appendChild(testCard);
});
// Update search stats
updateSearchStats();
}
function generateTimeline(runs) {
if (runs.length === 0) return '';
// Sort runs by timestamp to ensure chronological order
const sortedRuns = [...runs].sort((a, b) => a.ts - b.ts);
let html = '';
sortedRuns.forEach(run => {
const width = 100 / sortedRuns.length;
const statusClass = run.status === 'pass' ? 'timeline-pass' : 'timeline-fail';
html += '<div class="timeline-bar ' + statusClass + '" style="width: ' + width + '%"></div>';
});
return html;
}
function showTestDetails(testName) {
// Navigate to test details page
window.location.href = '/test/' + encodeURIComponent(testName);
}
function showTestList(status, title) {
const modal = document.getElementById('testModal');
const modalTitle = document.getElementById('modalTitle');
const testList = document.getElementById('testList');
modalTitle.textContent = title;
testList.innerHTML = '';
const { runs } = testData;
const testNames = Object.keys(runs);
testNames.forEach(testName => {
const testRuns = runs[testName];
const testStatus = determineTestStatus(testRuns);
// Filter tests based on status
if (status === 'total' || testStatus.key === status) {
const listItem = document.createElement('li');
listItem.className = 'test-list-item';
listItem.onclick = () => {
closeModal();
showTestDetails(testName);
};
listItem.innerHTML =
'<div class="test-name">' + testName + '</div>' +
'<div class="test-status">Status: ' + testStatus.text + '</div>';
testList.appendChild(listItem);
}
});
modal.style.display = 'block';
}
function closeModal() {
const modal = document.getElementById('testModal');
modal.style.display = 'none';
}
// Close modal when clicking outside of it
window.onclick = function(event) {
const modal = document.getElementById('testModal');
if (event.target === modal) {
modal.style.display = 'none';
}
}
// Refresh functionality
function refreshData() {
const button = document.getElementById('restart-btn');
// Show loading state
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Refreshing...';
button.disabled = true;
button.style.opacity = '0.7';
// Reload the page after a short delay to show the loading state
setTimeout(() => {
window.location.reload();
}, 1000);
}
// Search functionality
function setupSearch() {
const searchInput = document.getElementById('test-search');
if (searchInput) {
searchInput.addEventListener('input', filterTests);
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
searchInput.value = '';
filterTests();
searchInput.blur();
}
});
}
}
function filterTests() {
const searchTerm = document.getElementById('test-search').value.toLowerCase().trim();
const testCards = document.querySelectorAll('.test-card');
let visibleCount = 0;
testCards.forEach(card => {
const testName = card.getAttribute('data-test-name');
if (searchTerm === '' || testName.includes(searchTerm)) {
card.classList.remove('hidden');
visibleCount++;
} else {
card.classList.add('hidden');
}
});
updateSearchStats(visibleCount);
}
function updateSearchStats(visibleCount = null) {
const totalTests = document.querySelectorAll('.test-card').length;
const visibleTests = visibleCount !== null ? visibleCount : totalTests;
document.getElementById('total-tests-count').textContent = totalTests;
document.getElementById('search-results-count').textContent = visibleTests;
// Update search stats color based on results
const searchStats = document.querySelector('.search-stats');
if (visibleTests === 0 && document.getElementById('test-search').value.trim() !== '') {
searchStats.style.color = '#ef4444'; // Red when no results
} else {
searchStats.style.color = '#94a3b8'; // Default color
}
}
// Load data when page loads
loadTestData();
// Setup search after data loads
document.addEventListener('DOMContentLoaded', function() {
// Search will be set up after test data loads
});
// Refresh data every 30 seconds
setInterval(loadTestData, 30000);
</script>
</body>
</html>
`;
}