flakeiq 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +423 -0
- package/bin/flakeiq.js +173 -0
- package/lib/classify.js +38 -0
- package/lib/config.js +51 -0
- package/lib/seed.js +30 -0
- package/lib/serve.js +59 -0
- package/lib/setup.js +79 -0
- package/lib/utils.js +40 -0
- package/package.json +55 -0
- package/python/classify.py +191 -0
- package/python/dashboard.py +321 -0
- package/python/requirements.txt +2 -0
- package/python/seed.py +256 -0
- package/python/web/static/app.js +296 -0
- package/python/web/static/index.html +43 -0
- package/python/web/static/style.css +46 -0
- package/reporter/flake-reporter.js +93 -0
- package/reporter/index.js +1 -0
- package/scripts/postinstall.js +27 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
const COLORS = { REAL_BUG: '#f85149', TIMEOUT_FLAKE: '#d29922', DEVICE_FLAKE: '#58a6ff', LOCATOR_FLAKE: '#bc8cff', UNKNOWN: '#8b949e', UNCLASSIFIED: '#484f58' };
|
|
2
|
+
|
|
3
|
+
async function loadData() {
|
|
4
|
+
const [stats, trend, pie, action, platform, volume, duration, clsTrend, heatmap, topFlakes, devices, latestSession, sessions] = await Promise.all([
|
|
5
|
+
fetch('/api/stats').then(r=>r.json()),
|
|
6
|
+
fetch('/api/flake-rate').then(r=>r.json()),
|
|
7
|
+
fetch('/api/breakdown').then(r=>r.json()),
|
|
8
|
+
fetch('/api/by-action').then(r=>r.json()),
|
|
9
|
+
fetch('/api/by-platform').then(r=>r.json()),
|
|
10
|
+
fetch('/api/volume').then(r=>r.json()),
|
|
11
|
+
fetch('/api/duration-dist').then(r=>r.json()),
|
|
12
|
+
fetch('/api/classification-trend').then(r=>r.json()),
|
|
13
|
+
fetch('/api/heatmap').then(r=>r.json()),
|
|
14
|
+
fetch('/api/top-flakes').then(r=>r.json()),
|
|
15
|
+
fetch('/api/devices').then(r=>r.json()),
|
|
16
|
+
fetch('/api/latest-session').then(r=>r.json()),
|
|
17
|
+
fetch('/api/sessions').then(r=>r.json()),
|
|
18
|
+
]);
|
|
19
|
+
document.getElementById('recordCount').textContent = stats.total_runs;
|
|
20
|
+
document.getElementById('dateRange').textContent = stats.date_range || '';
|
|
21
|
+
renderStats(stats);
|
|
22
|
+
renderTrend(trend);
|
|
23
|
+
renderPie(pie);
|
|
24
|
+
renderActionChart(action);
|
|
25
|
+
renderPlatformChart(platform);
|
|
26
|
+
renderVolumeChart(volume);
|
|
27
|
+
renderDurationChart(duration);
|
|
28
|
+
renderClassificationTrend(clsTrend);
|
|
29
|
+
renderHeatmap(heatmap);
|
|
30
|
+
renderTable('flakeTableBody', topFlakes, true);
|
|
31
|
+
renderTable('deviceTableBody', devices, false);
|
|
32
|
+
renderLatestSession(latestSession);
|
|
33
|
+
renderSessions(sessions);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderStats(s) {
|
|
37
|
+
const flakeColor = s.flake_rate > 20 ? 'down' : s.flake_rate > 10 ? 'warn' : 'up';
|
|
38
|
+
document.getElementById('statsRow').innerHTML = `
|
|
39
|
+
<div class="stat-card info"><div class="value">${s.total_runs}</div><div class="label">Total Runs</div></div>
|
|
40
|
+
<div class="stat-card down"><div class="value">${s.fail_count}</div><div class="label">Failures</div><div class="trend">${(s.fail_count/s.total_runs*100).toFixed(1)}% fail rate</div></div>
|
|
41
|
+
<div class="stat-card ${flakeColor}"><div class="value">${s.flake_rate}%</div><div class="label">Flake Rate</div></div>
|
|
42
|
+
<div class="stat-card warn"><div class="value">${s.real_bug_count}</div><div class="label">Real Bugs</div></div>
|
|
43
|
+
<div class="stat-card purple"><div class="value">${s.classified_count}</div><div class="label">Classified</div><div class="trend">${s.classified_count > 0 ? (s.real_bug_count/s.classified_count*100).toFixed(0)+'% are bugs' : ''}</div></div>
|
|
44
|
+
<div class="stat-card info"><div class="value">${s.avg_duration}s</div><div class="label">Avg Duration</div></div>
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderTrend(data) {
|
|
49
|
+
const ctx = document.getElementById('trendChart').getContext('2d');
|
|
50
|
+
new Chart(ctx, {
|
|
51
|
+
type: 'line', data: {
|
|
52
|
+
labels: data.map(d=>d.day),
|
|
53
|
+
datasets: [{
|
|
54
|
+
label: 'Flake Rate %', data: data.map(d=>d.rate),
|
|
55
|
+
borderColor: '#58a6ff', backgroundColor: 'rgba(88,166,255,0.08)',
|
|
56
|
+
fill: true, tension: 0.3, pointRadius: 2, pointHoverRadius: 5,
|
|
57
|
+
}]
|
|
58
|
+
}, options: {
|
|
59
|
+
responsive: true, maintainAspectRatio: false,
|
|
60
|
+
scales: {
|
|
61
|
+
x: { ticks: { color: '#8b949e', maxTicksLimit: 14, font: {size: 10} }, grid: {color: '#21262d'} },
|
|
62
|
+
y: { ticks: { color: '#8b949e', font: {size: 10} }, beginAtZero: true, grid: {color: '#21262d'} }
|
|
63
|
+
},
|
|
64
|
+
plugins: { legend: { labels: { color: '#c9d1d9', font: {size: 11} } } }
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderPie(data) {
|
|
70
|
+
const ctx = document.getElementById('pieChart').getContext('2d');
|
|
71
|
+
new Chart(ctx, {
|
|
72
|
+
type: 'doughnut', data: {
|
|
73
|
+
labels: data.map(d=>d.classification),
|
|
74
|
+
datasets: [{ data: data.map(d=>d.count), backgroundColor: data.map(d=>COLORS[d.classification]||'#484f58'), borderWidth: 0 }]
|
|
75
|
+
}, options: {
|
|
76
|
+
responsive: true, maintainAspectRatio: false,
|
|
77
|
+
plugins: { legend: { position: 'right', labels: { color: '#c9d1d9', font: {size: 11}, padding: 12 } } }
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderActionChart(data) {
|
|
83
|
+
if (!data.length) return;
|
|
84
|
+
const ctx = document.getElementById('actionChart').getContext('2d');
|
|
85
|
+
const colors = { fill: '#58a6ff', swipe: '#d29922', tap: '#3fb950', press: '#bc8cff', scrollIntoView: '#f0883e', other: '#8b949e' };
|
|
86
|
+
new Chart(ctx, {
|
|
87
|
+
type: 'bar', data: {
|
|
88
|
+
labels: data.map(d=>d.action_type),
|
|
89
|
+
datasets: [{
|
|
90
|
+
label: 'Flake Rate %', data: data.map(d=>d.rate),
|
|
91
|
+
backgroundColor: data.map(d=>colors[d.action_type]||'#8b949e'),
|
|
92
|
+
borderRadius: 4, borderSkipped: false,
|
|
93
|
+
}]
|
|
94
|
+
}, options: {
|
|
95
|
+
responsive: true, maintainAspectRatio: false,
|
|
96
|
+
scales: {
|
|
97
|
+
x: { ticks: { color: '#8b949e', font: {size: 10} }, grid: {display: false} },
|
|
98
|
+
y: { ticks: { color: '#8b949e', font: {size: 10} }, beginAtZero: true, grid: {color: '#21262d'} }
|
|
99
|
+
},
|
|
100
|
+
plugins: { legend: { display: false } }
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function renderPlatformChart(data) {
|
|
106
|
+
const ctx = document.getElementById('platformChart').getContext('2d');
|
|
107
|
+
new Chart(ctx, {
|
|
108
|
+
type: 'bar', data: {
|
|
109
|
+
labels: data.map(d=>d.platform),
|
|
110
|
+
datasets: [
|
|
111
|
+
{ label: 'Pass', data: data.map(d=>d.pass_count), backgroundColor: '#1b3a2d', borderRadius: 4 },
|
|
112
|
+
{ label: 'Fail', data: data.map(d=>d.fail_count), backgroundColor: '#3d1f1f', borderRadius: 4 },
|
|
113
|
+
]
|
|
114
|
+
}, options: {
|
|
115
|
+
responsive: true, maintainAspectRatio: false,
|
|
116
|
+
scales: {
|
|
117
|
+
x: { stacked: true, ticks: { color: '#8b949e', font: {size: 10} }, grid: {display: false} },
|
|
118
|
+
y: { stacked: true, ticks: { color: '#8b949e', font: {size: 10} }, beginAtZero: true, grid: {color: '#21262d'} }
|
|
119
|
+
},
|
|
120
|
+
plugins: { legend: { labels: { color: '#c9d1d9', font: {size: 11} } } }
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderVolumeChart(data) {
|
|
126
|
+
const ctx = document.getElementById('volumeChart').getContext('2d');
|
|
127
|
+
new Chart(ctx, {
|
|
128
|
+
type: 'bar', data: {
|
|
129
|
+
labels: data.map(d=>d.day),
|
|
130
|
+
datasets: [
|
|
131
|
+
{ label: 'Pass', data: data.map(d=>d.pass_count), backgroundColor: '#1b3a2d', borderRadius: 2 },
|
|
132
|
+
{ label: 'Fail', data: data.map(d=>d.fail_count), backgroundColor: '#3d1f1f', borderRadius: 2 },
|
|
133
|
+
]
|
|
134
|
+
}, options: {
|
|
135
|
+
responsive: true, maintainAspectRatio: false,
|
|
136
|
+
scales: {
|
|
137
|
+
x: { ticks: { color: '#8b949e', maxTicksLimit: 10, font: {size: 9} }, grid: {display: false}, stacked: true },
|
|
138
|
+
y: { ticks: { color: '#8b949e', font: {size: 10} }, beginAtZero: true, grid: {color: '#21262d'}, stacked: true }
|
|
139
|
+
},
|
|
140
|
+
plugins: { legend: { labels: { color: '#c9d1d9', font: {size: 11} } } }
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderDurationChart(data) {
|
|
146
|
+
const ctx = document.getElementById('durationChart').getContext('2d');
|
|
147
|
+
new Chart(ctx, {
|
|
148
|
+
type: 'bar', data: {
|
|
149
|
+
labels: data.map(d=>d.bucket),
|
|
150
|
+
datasets: [{
|
|
151
|
+
label: 'Failures', data: data.map(d=>d.count),
|
|
152
|
+
backgroundColor: '#f8514944', borderColor: '#f85149', borderWidth: 1,
|
|
153
|
+
borderRadius: 3,
|
|
154
|
+
}]
|
|
155
|
+
}, options: {
|
|
156
|
+
responsive: true, maintainAspectRatio: false,
|
|
157
|
+
scales: {
|
|
158
|
+
x: { ticks: { color: '#8b949e', font: {size: 9} }, grid: {display: false} },
|
|
159
|
+
y: { ticks: { color: '#8b949e', font: {size: 10} }, beginAtZero: true, grid: {color: '#21262d'} }
|
|
160
|
+
},
|
|
161
|
+
plugins: { legend: { display: false } }
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderClassificationTrend(data) {
|
|
167
|
+
if (!data.length) return;
|
|
168
|
+
const categories = [...new Set(data.map(d=>d.classification))].filter(Boolean);
|
|
169
|
+
const days = [...new Set(data.map(d=>d.day))].sort();
|
|
170
|
+
const datasets = categories.map(cat => ({
|
|
171
|
+
label: cat,
|
|
172
|
+
data: days.map(day => { const f = data.find(d=>d.day===day&&d.classification===cat); return f ? f.count : 0; }),
|
|
173
|
+
backgroundColor: COLORS[cat] + '66',
|
|
174
|
+
borderColor: COLORS[cat],
|
|
175
|
+
borderWidth: 1,
|
|
176
|
+
fill: true,
|
|
177
|
+
tension: 0.3,
|
|
178
|
+
pointRadius: 0,
|
|
179
|
+
}));
|
|
180
|
+
const ctx = document.getElementById('classificationTrendChart').getContext('2d');
|
|
181
|
+
new Chart(ctx, {
|
|
182
|
+
type: 'line', data: { labels: days, datasets },
|
|
183
|
+
options: {
|
|
184
|
+
responsive: true, maintainAspectRatio: false,
|
|
185
|
+
scales: {
|
|
186
|
+
x: { ticks: { color: '#8b949e', maxTicksLimit: 10, font: {size: 9} }, grid: {color: '#21262d'} },
|
|
187
|
+
y: { stacked: true, ticks: { color: '#8b949e', font: {size: 10} }, beginAtZero: true, grid: {color: '#21262d'} }
|
|
188
|
+
},
|
|
189
|
+
plugins: {
|
|
190
|
+
legend: { labels: { color: '#c9d1d9', font: {size: 10} } },
|
|
191
|
+
tooltip: { mode: 'index', intersect: false }
|
|
192
|
+
},
|
|
193
|
+
interaction: { mode: 'nearest', axis: 'x', intersect: false }
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderHeatmap(data) {
|
|
199
|
+
const container = document.getElementById('heatmapContainer');
|
|
200
|
+
if (!data.length) { container.innerHTML = '<span style="color:var(--muted)">Not enough data.</span>'; return; }
|
|
201
|
+
const screens = [...new Set(data.map(d=>d.screen))].sort();
|
|
202
|
+
const days = [...new Set(data.map(d=>d.day))].sort();
|
|
203
|
+
const lookup = {}; data.forEach(d => { lookup[d.screen+'|'+d.day] = d.rate; });
|
|
204
|
+
const heatColor = v => { if (v === 0) return '#1b3a2d'; if (v < 10) return '#2d4a1a'; if (v < 20) return '#3d3a1a'; if (v < 35) return '#4a2a1a'; if (v < 50) return '#5a1a1a'; return '#6a0a0a'; };
|
|
205
|
+
let html = '<table style="border-collapse:collapse"><tr><th style="padding:3px 8px;color:var(--muted);font-size:10px;font-weight:600">Screen</th>';
|
|
206
|
+
days.forEach(d => { html += `<th style="padding:3px 4px;color:var(--muted);font-size:9px;font-weight:600;text-align:center">${d.slice(5)}</th>`; });
|
|
207
|
+
html += '<th style="padding:3px 8px;color:var(--muted);font-size:10px;font-weight:600;text-align:center">Avg</th></tr>';
|
|
208
|
+
screens.forEach(s => {
|
|
209
|
+
html += `<tr><td style="padding:3px 8px;color:var(--text);font-size:11px">${s}</td>`;
|
|
210
|
+
let sum = 0, cnt = 0;
|
|
211
|
+
days.forEach(d => {
|
|
212
|
+
const v = lookup[s+'|'+d];
|
|
213
|
+
if (v !== undefined) { sum += v; cnt++; }
|
|
214
|
+
const bg = v !== undefined ? heatColor(v) : '#161b22';
|
|
215
|
+
const txt = v !== undefined ? v.toFixed(0) + '%' : '—';
|
|
216
|
+
html += `<td style="padding:3px 4px;text-align:center;background:${bg};color:${v > 30 ? '#fff' : 'var(--text)'};border-radius:3px;font-size:11px;font-weight:${v !== undefined ? '600' : '400'}">${txt}</td>`;
|
|
217
|
+
});
|
|
218
|
+
const avg = cnt > 0 ? (sum / cnt).toFixed(0) + '%' : '—';
|
|
219
|
+
html += `<td style="padding:3px 8px;text-align:center;color:var(--muted);font-size:11px;font-weight:600">${avg}</td></tr>`;
|
|
220
|
+
});
|
|
221
|
+
html += '</table>';
|
|
222
|
+
container.innerHTML = html;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renderTable(tbodyId, rows, isTest) {
|
|
226
|
+
const tbody = document.getElementById(tbodyId);
|
|
227
|
+
if (!rows.length) { tbody.innerHTML = '<tr><td colspan="7" style="color:var(--muted);text-align:center;padding:20px;font-size:13px">No data yet. Run tests first.</td></tr>'; return; }
|
|
228
|
+
tbody.innerHTML = rows.map(r => {
|
|
229
|
+
const rate = (r.fail_count / r.total * 100);
|
|
230
|
+
const pct = Math.min(100, Math.round(rate));
|
|
231
|
+
const cls = r.common_classification || 'UNKNOWN';
|
|
232
|
+
const name = isTest ? r.test_name : r.device_id;
|
|
233
|
+
const platform = r.platform || '';
|
|
234
|
+
const subtitle = isTest ? (r.screen_name || '') : '';
|
|
235
|
+
const barColor = pct > 50 ? 'var(--red)' : pct > 25 ? 'var(--yellow)' : pct > 10 ? 'var(--orange)' : 'var(--green)';
|
|
236
|
+
return `<tr>
|
|
237
|
+
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis">${name}</td>
|
|
238
|
+
<td>${platform ? `<span class="badge badge-${platform}">${platform}</span>` : ''}</td>
|
|
239
|
+
<td>${subtitle ? `<span class="badge">${subtitle}</span>` : ''}</td>
|
|
240
|
+
<td style="font-weight:600">${rate.toFixed(1)}%</td>
|
|
241
|
+
<td>${r.total}</td>
|
|
242
|
+
<td><div class="flake-bar"><div class="flake-bar-fill" style="width:${pct}%;background:${barColor}"></div></div></td>
|
|
243
|
+
<td><span class="badge badge-${cls}">${cls}</span></td>
|
|
244
|
+
</tr>`;
|
|
245
|
+
}).join('');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderLatestSession(s) {
|
|
249
|
+
const container = document.getElementById('latestSessionContainer');
|
|
250
|
+
if (!s || !s.total_runs) {
|
|
251
|
+
container.innerHTML = '<span style="color:var(--muted)">No sessions recorded yet.</span>';
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const rate = s.total_runs > 0 ? (s.passed / s.total_runs * 100).toFixed(0) : 0;
|
|
255
|
+
let testsHtml = s.tests.map(t => {
|
|
256
|
+
const cls = t.result === 'passed' ? 'badge-pass' : 'badge-fail';
|
|
257
|
+
return `<span class="test-badge badge ${cls}" title="${t.test_name} (${t.duration_ms}ms)">${t.test_name}</span>`;
|
|
258
|
+
}).join('');
|
|
259
|
+
container.innerHTML = `
|
|
260
|
+
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px">
|
|
261
|
+
<div><span style="font-size:24px;font-weight:700">${s.total_runs}</span><span style="color:var(--muted);margin-left:6px;font-size:12px">tests</span></div>
|
|
262
|
+
<div><span style="font-size:24px;font-weight:700;color:var(--green)">${s.passed}</span><span style="color:var(--muted);margin-left:6px;font-size:12px">passed</span></div>
|
|
263
|
+
<div><span style="font-size:24px;font-weight:700;color:var(--red)">${s.failed}</span><span style="color:var(--muted);margin-left:6px;font-size:12px">failed</span></div>
|
|
264
|
+
<div><span style="font-size:24px;font-weight:700">${rate}%</span><span style="color:var(--muted);margin-left:6px;font-size:12px">pass rate</span></div>
|
|
265
|
+
<div><span style="font-size:20px;font-weight:600;color:var(--accent)">${s.platform || '—'}</span><span style="color:var(--muted);margin-left:6px;font-size:12px">platform</span></div>
|
|
266
|
+
<div><span style="font-size:14px;color:var(--muted)">${s.avg_duration_s || 0}s avg</span></div>
|
|
267
|
+
</div>
|
|
268
|
+
<div class="session-row">${testsHtml}</div>
|
|
269
|
+
`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderSessions(sessions) {
|
|
273
|
+
const tbody = document.getElementById('sessionsTableBody');
|
|
274
|
+
if (!sessions.length) {
|
|
275
|
+
tbody.innerHTML = '<tr><td colspan="10" style="color:var(--muted);text-align:center;padding:20px">No sessions recorded.</td></tr>';
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
tbody.innerHTML = sessions.map(s => {
|
|
279
|
+
const rate = s.total_runs > 0 ? (s.passed / s.total_runs * 100).toFixed(0) : 0;
|
|
280
|
+
const passColor = rate >= 90 ? 'var(--green)' : rate >= 70 ? 'var(--yellow)' : 'var(--red)';
|
|
281
|
+
return `<tr>
|
|
282
|
+
<td style="font-family:monospace;font-size:12px;max-width:200px;overflow:hidden;text-overflow:ellipsis" title="${s.session_id}">${s.session_id.slice(0, 30)}</td>
|
|
283
|
+
<td>${s.platform || '—'}</td>
|
|
284
|
+
<td style="font-size:12px;max-width:160px;overflow:hidden;text-overflow:ellipsis" title="${s.device_id || ''}">${s.device_id ? s.device_id.slice(0, 25) : '—'}</td>
|
|
285
|
+
<td>${s.total_runs}</td>
|
|
286
|
+
<td style="color:var(--green)">${s.passed}</td>
|
|
287
|
+
<td style="color:var(--red)">${s.failed}</td>
|
|
288
|
+
<td style="font-weight:600;color:${passColor}">${rate}%</td>
|
|
289
|
+
<td>${s.avg_duration_s || 0}s</td>
|
|
290
|
+
<td style="font-size:11px;color:var(--muted)">${s.first_run ? s.first_run.slice(0, 16).replace('T', ' ') : '—'}</td>
|
|
291
|
+
<td style="font-size:11px;color:var(--muted)">${s.last_run ? s.last_run.slice(0, 16).replace('T', ' ') : '—'}</td>
|
|
292
|
+
</tr>`;
|
|
293
|
+
}).join('');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
loadData();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>FlakeIQ Dashboard</title>
|
|
7
|
+
<link rel="stylesheet" href="/static/style.css">
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<h1>FlakeIQ</h1>
|
|
12
|
+
<p class="subtitle">Test flake tracker — <span id="recordCount">0</span> records · <span id="dateRange"></span></p>
|
|
13
|
+
<div class="dashboard">
|
|
14
|
+
<div class="stats-row" id="statsRow"></div>
|
|
15
|
+
|
|
16
|
+
<div class="card"><h2>Flake Rate (7d Moving Avg)</h2><canvas id="trendChart"></canvas></div>
|
|
17
|
+
<div class="card"><h2>Failure Breakdown</h2><canvas id="pieChart"></canvas></div>
|
|
18
|
+
|
|
19
|
+
<div class="card"><h2>Flake Rate by Action Type</h2><canvas id="actionChart"></canvas></div>
|
|
20
|
+
<div class="card"><h2>Platform Comparison</h2><canvas id="platformChart"></canvas></div>
|
|
21
|
+
|
|
22
|
+
<div class="card"><h2>Daily Test Volume</h2><canvas id="volumeChart"></canvas></div>
|
|
23
|
+
<div class="card"><h2>Failure Duration Distribution</h2><canvas id="durationChart"></canvas></div>
|
|
24
|
+
|
|
25
|
+
<div class="card full"><h2>Classification Trend Over Time</h2><canvas id="classificationTrendChart" style="max-height:200px"></canvas></div>
|
|
26
|
+
|
|
27
|
+
<div class="card full"><h2>Flake Heatmap (Screen × Day)</h2><div style="overflow-x:auto;font-size:12px" id="heatmapContainer"></div></div>
|
|
28
|
+
|
|
29
|
+
<div class="card full"><h2>Top Flaky Tests</h2>
|
|
30
|
+
<table><thead><tr><th>Test</th><th>Platform</th><th>Screen</th><th>Fail Rate</th><th>Runs</th><th>Flake Bar</th><th>Common Issue</th></tr></thead><tbody id="flakeTableBody"></tbody></table></div>
|
|
31
|
+
|
|
32
|
+
<div class="card full"><h2>Device Health</h2>
|
|
33
|
+
<table><thead><tr><th>Device</th><th>Platform</th><th>Runs</th><th>Fail Rate</th><th>Flake Bar</th><th>Common Issue</th></tr></thead><tbody id="deviceTableBody"></tbody></table></div>
|
|
34
|
+
|
|
35
|
+
<div class="card full"><h2>Latest Session</h2>
|
|
36
|
+
<div id="latestSessionContainer"><span style="color:var(--muted)">Loading...</span></div></div>
|
|
37
|
+
|
|
38
|
+
<div class="card full"><h2>Sessions</h2>
|
|
39
|
+
<div style="overflow-x:auto"><table><thead><tr><th>Session</th><th>Platform</th><th>Device</th><th>Runs</th><th>Passed</th><th>Failed</th><th>Pass Rate</th><th>Avg Duration</th><th>First Run</th><th>Last Run</th></tr></thead><tbody id="sessionsTableBody"></tbody></table></div></div>
|
|
40
|
+
</div>
|
|
41
|
+
<script src="/static/app.js"></script>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0d1117; --card: #161b22; --border: #30363d;
|
|
3
|
+
--text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff;
|
|
4
|
+
--green: #3fb950; --red: #f85149; --yellow: #d29922;
|
|
5
|
+
--purple: #bc8cff; --orange: #f0883e;
|
|
6
|
+
}
|
|
7
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); padding: 24px; }
|
|
9
|
+
h1 { font-size: 24px; font-weight: 700; background: linear-gradient(135deg, var(--accent), var(--purple)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 4px; }
|
|
10
|
+
.subtitle { color: var(--muted); font-size: 14px; margin-bottom: 24px; }
|
|
11
|
+
.dashboard { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; max-width: 1500px; margin: 0 auto; }
|
|
12
|
+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 20px; transition: border-color 0.2s; }
|
|
13
|
+
.card:hover { border-color: #484f58; }
|
|
14
|
+
.card.full { grid-column: 1 / -1; }
|
|
15
|
+
.card h2 { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; }
|
|
16
|
+
.card h3 { font-size: 14px; font-weight: 600; color: var(--text); margin-bottom: 12px; }
|
|
17
|
+
canvas { max-height: 260px; width: 100% !important; }
|
|
18
|
+
.stats-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; grid-column: 1 / -1; }
|
|
19
|
+
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 16px 20px; flex: 1; min-width: 140px; }
|
|
20
|
+
.stat-card .value { font-size: 28px; font-weight: 700; line-height: 1.2; }
|
|
21
|
+
.stat-card .label { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
|
22
|
+
.stat-card .trend { font-size: 11px; margin-top: 4px; }
|
|
23
|
+
.stat-card.up { border-left: 3px solid var(--green); }
|
|
24
|
+
.stat-card.down { border-left: 3px solid var(--red); }
|
|
25
|
+
.stat-card.warn { border-left: 3px solid var(--yellow); }
|
|
26
|
+
.stat-card.info { border-left: 3px solid var(--accent); }
|
|
27
|
+
.stat-card.purple { border-left: 3px solid var(--purple); }
|
|
28
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
29
|
+
th { text-align: left; padding: 8px 10px; border-bottom: 2px solid var(--border); color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; }
|
|
30
|
+
td { padding: 8px 10px; border-bottom: 1px solid #21262d; white-space: nowrap; }
|
|
31
|
+
tr:hover td { background: rgba(255,255,255,0.02); }
|
|
32
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
|
33
|
+
.badge-pass { background: #1b3a2d; color: var(--green); }
|
|
34
|
+
.badge-fail { background: #3d1f1f; color: var(--red); }
|
|
35
|
+
.badge-REAL_BUG { background: #3d1f1f; color: var(--red); }
|
|
36
|
+
.badge-TIMEOUT_FLAKE { background: #3d2e1a; color: var(--yellow); }
|
|
37
|
+
.badge-DEVICE_FLAKE { background: #1f2d3d; color: var(--accent); }
|
|
38
|
+
.badge-LOCATOR_FLAKE { background: #2d1f3d; color: var(--purple); }
|
|
39
|
+
.badge-UNKNOWN { background: #21262d; color: var(--muted); }
|
|
40
|
+
.flake-bar { height: 6px; border-radius: 3px; background: #21262d; overflow: hidden; min-width: 80px; }
|
|
41
|
+
.flake-bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
|
|
42
|
+
.chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; grid-column: 1 / -1; }
|
|
43
|
+
.session-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
44
|
+
.session-row .test-badge { padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 500; border: 1px solid var(--border); }
|
|
45
|
+
.detail-toggle { cursor: pointer; color: var(--accent); font-size: 11px; }
|
|
46
|
+
@media (max-width: 1000px) { .dashboard { grid-template-columns: 1fr; } .chart-grid { grid-template-columns: 1fr; } }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
class FlakeReporter {
|
|
4
|
+
constructor(options) {
|
|
5
|
+
this.stepsMap = new Map();
|
|
6
|
+
this.outputFile = (options && options.outputFile) || 'flake-results.jsonl';
|
|
7
|
+
this.sessionId = 'run_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
onStepBegin(test, result, step) {
|
|
11
|
+
if (step.category !== 'pw:api') return;
|
|
12
|
+
const key = test.id + '::' + result.retry;
|
|
13
|
+
if (!this.stepsMap.has(key)) this.stepsMap.set(key, []);
|
|
14
|
+
const steps = this.stepsMap.get(key);
|
|
15
|
+
steps.push(step.title);
|
|
16
|
+
if (steps.length > 10) steps.shift();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
onTestEnd(test, result) {
|
|
20
|
+
const key = test.id + '::' + result.retry;
|
|
21
|
+
const lastActions = this.stepsMap.get(key) || [];
|
|
22
|
+
this.stepsMap.delete(key);
|
|
23
|
+
|
|
24
|
+
const findAnnotation = (type) => {
|
|
25
|
+
const a = result.annotations.find(x => x.type === type);
|
|
26
|
+
return a ? a.description : '';
|
|
27
|
+
};
|
|
28
|
+
const platform = findAnnotation('device.platform') || test._projectId || '';
|
|
29
|
+
const deviceId = findAnnotation('device.id') || '';
|
|
30
|
+
|
|
31
|
+
const record = {
|
|
32
|
+
test_file: test.location.file,
|
|
33
|
+
test_name: test.title,
|
|
34
|
+
platform,
|
|
35
|
+
device_id: deviceId,
|
|
36
|
+
duration_ms: result.duration,
|
|
37
|
+
result: result.status,
|
|
38
|
+
error_message: result.error ? result.error.message || '' : '',
|
|
39
|
+
last_actions: lastActions,
|
|
40
|
+
classification: null,
|
|
41
|
+
classification_reason: null,
|
|
42
|
+
screen_name: guessScreen(test.title, lastActions),
|
|
43
|
+
session_id: this.sessionId,
|
|
44
|
+
run_at: new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
fs.appendFileSync(this.outputFile, JSON.stringify(record) + '\n');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SCREEN_MAP = {
|
|
52
|
+
alert: ['alerts', 'alert', 'dialog', 'modal'],
|
|
53
|
+
animation: ['animation', 'animate', 'transition'],
|
|
54
|
+
calendar: ['calendar', 'date', 'picker'],
|
|
55
|
+
form: ['formcontrols', 'form', 'input', 'textfield'],
|
|
56
|
+
gesture: ['gestures', 'gesture', 'swipe', 'pinch', 'pan'],
|
|
57
|
+
list: ['lists', 'list', 'scroll', 'flatlist'],
|
|
58
|
+
login: ['login', 'register', 'signin', 'auth', 'validation'],
|
|
59
|
+
media: ['media', 'image', 'video', 'photo'],
|
|
60
|
+
profile: ['profile', 'edit', 'save', 'logout', 'toggle'],
|
|
61
|
+
signature: ['signature', 'draw', 'canvas'],
|
|
62
|
+
home: ['home', 'landing', 'main'],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function guessScreen(testName, actions) {
|
|
66
|
+
// Pattern: "ScreenName - action description" or "screen name - action"
|
|
67
|
+
const dashIdx = testName.indexOf(' - ');
|
|
68
|
+
if (dashIdx !== -1) {
|
|
69
|
+
const prefix = testName.slice(0, dashIdx).trim().toLowerCase();
|
|
70
|
+
for (const [screen, aliases] of Object.entries(SCREEN_MAP)) {
|
|
71
|
+
if (aliases.some(a => prefix === a || prefix.startsWith(a))) return screen;
|
|
72
|
+
}
|
|
73
|
+
// If prefix is a simple word, return it directly (e.g. "Alerts" -> "alerts")
|
|
74
|
+
if (/^[a-z]+$/.test(prefix)) return prefix;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fallback: keyword match in full test name
|
|
78
|
+
const lower = testName.toLowerCase();
|
|
79
|
+
for (const [screen, keywords] of Object.entries(SCREEN_MAP)) {
|
|
80
|
+
if (keywords.some(k => lower.includes(k))) return screen;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Last resort: check actions
|
|
84
|
+
for (const action of actions) {
|
|
85
|
+
const lowerAction = action.toLowerCase();
|
|
86
|
+
for (const [screen, keywords] of Object.entries(SCREEN_MAP)) {
|
|
87
|
+
if (keywords.some(k => lowerAction.includes(k))) return screen;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return 'unknown';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = FlakeReporter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('./flake-reporter.js');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { isVenvReady } = require('../lib/config');
|
|
4
|
+
const { setup } = require('../lib/setup');
|
|
5
|
+
|
|
6
|
+
console.log('');
|
|
7
|
+
console.log(' FlakeIQ — setting up Python environment...');
|
|
8
|
+
|
|
9
|
+
if (isVenvReady()) {
|
|
10
|
+
console.log(' Python environment already ready.');
|
|
11
|
+
} else {
|
|
12
|
+
const ok = setup();
|
|
13
|
+
if (!ok) {
|
|
14
|
+
console.log('');
|
|
15
|
+
console.log(' Setup incomplete. FlakeIQ reporter will work, but');
|
|
16
|
+
console.log(' dashboard/classifier need Python 3.11+ installed.');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log('');
|
|
21
|
+
console.log(' Next steps:');
|
|
22
|
+
console.log(' Add to playwright.config.ts:');
|
|
23
|
+
console.log(" reporter: [['flakeiq/reporter']]");
|
|
24
|
+
console.log('');
|
|
25
|
+
console.log(' Start dashboard:');
|
|
26
|
+
console.log(' npx flakeiq serve');
|
|
27
|
+
console.log('');
|