test-reporting 3.0.2__tar.gz → 3.1.0__tar.gz
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.
- {test_reporting-3.0.2 → test_reporting-3.1.0}/PKG-INFO +1 -1
- {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/publisher.py +117 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/templates/index.html +8 -3
- {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/templates/project.html +47 -35
- {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/templates/run.html +59 -24
- {test_reporting-3.0.2 → test_reporting-3.1.0}/setup.py +1 -1
- {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/PKG-INFO +1 -1
- {test_reporting-3.0.2 → test_reporting-3.1.0}/LICENSE +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/README.md +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/__init__.py +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/classifier.py +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/cli.py +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/config.py +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/plugin.py +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/storage.py +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/setup.cfg +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/SOURCES.txt +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/dependency_links.txt +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/entry_points.txt +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/requires.txt +0 -0
- {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/top_level.txt +0 -0
|
@@ -75,6 +75,7 @@ class Publisher:
|
|
|
75
75
|
# Group tests by file and build all_tests (lean, for analytics)
|
|
76
76
|
files: Dict[str, Dict] = {}
|
|
77
77
|
failed_tests = []
|
|
78
|
+
passed_tests = []
|
|
78
79
|
all_tests = [] # Every test result — used for cross-run analytics
|
|
79
80
|
|
|
80
81
|
for t in tests:
|
|
@@ -107,8 +108,18 @@ class Publisher:
|
|
|
107
108
|
'duration_seconds': duration,
|
|
108
109
|
'screenshot_path': t.get('screenshot_path'),
|
|
109
110
|
'trace_path': t.get('trace_path'),
|
|
111
|
+
'logs': t.get('logs'),
|
|
110
112
|
'is_soft_fail': t.get('is_soft_fail', False),
|
|
111
113
|
})
|
|
114
|
+
|
|
115
|
+
# Full passed test detail
|
|
116
|
+
elif status == 'passed':
|
|
117
|
+
passed_tests.append({
|
|
118
|
+
'test_name': t.get('test_name'),
|
|
119
|
+
'file_name': fname,
|
|
120
|
+
'duration_seconds': duration,
|
|
121
|
+
'logs': t.get('logs'),
|
|
122
|
+
})
|
|
112
123
|
|
|
113
124
|
# Lean record for every test (used in analytics)
|
|
114
125
|
all_tests.append({
|
|
@@ -149,6 +160,7 @@ class Publisher:
|
|
|
149
160
|
'duration_seconds': round(run_data.get('duration_seconds', 0), 2),
|
|
150
161
|
'test_files': file_list,
|
|
151
162
|
'failed_tests': failed_tests,
|
|
163
|
+
'passed_tests': passed_tests,
|
|
152
164
|
'all_tests': all_tests,
|
|
153
165
|
}
|
|
154
166
|
|
|
@@ -537,12 +549,17 @@ class Publisher:
|
|
|
537
549
|
# ------------------------------------------------------------------
|
|
538
550
|
|
|
539
551
|
def _publish_local(self, run_id: str, run_file: Dict[str, Any]):
|
|
552
|
+
import shutil
|
|
540
553
|
store = self.config.STORE_PATH
|
|
541
554
|
store.mkdir(parents=True, exist_ok=True)
|
|
542
555
|
project = run_file['project_name']
|
|
543
556
|
runs_dir = store / 'runs' / project
|
|
544
557
|
runs_dir.mkdir(parents=True, exist_ok=True)
|
|
545
558
|
|
|
559
|
+
# Copy artifacts (screenshots, traces) to store BEFORE writing JSON
|
|
560
|
+
# This updates the paths in run_file
|
|
561
|
+
self._copy_artifacts_local(run_file, store)
|
|
562
|
+
|
|
546
563
|
run_path = runs_dir / f"{run_id}.json"
|
|
547
564
|
with open(run_path, 'w', encoding='utf-8') as f:
|
|
548
565
|
json.dump(run_file, f, indent=2)
|
|
@@ -571,6 +588,56 @@ class Publisher:
|
|
|
571
588
|
json.dump(data, f, indent=2)
|
|
572
589
|
print(f"[Reporting] data.json updated: {data_path}")
|
|
573
590
|
|
|
591
|
+
def _copy_artifacts_local(self, run_file: Dict[str, Any], store: Path):
|
|
592
|
+
"""Copy screenshots and traces to the store and update paths."""
|
|
593
|
+
import shutil
|
|
594
|
+
from pathlib import Path as P
|
|
595
|
+
|
|
596
|
+
artifacts_dir = store / 'artifacts'
|
|
597
|
+
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
598
|
+
|
|
599
|
+
base_dir = P.cwd()
|
|
600
|
+
print(f"[Reporting] Base directory for artifacts: {base_dir}")
|
|
601
|
+
print(f"[Reporting] Artifacts will be copied to: {artifacts_dir}")
|
|
602
|
+
|
|
603
|
+
artifact_count = 0
|
|
604
|
+
|
|
605
|
+
# Process failed tests to copy their artifacts
|
|
606
|
+
for test in run_file.get('failed_tests', []):
|
|
607
|
+
# Copy screenshot
|
|
608
|
+
if test.get('screenshot_path'):
|
|
609
|
+
# Normalize path separators
|
|
610
|
+
screenshot_rel = test['screenshot_path'].replace('\\', '/')
|
|
611
|
+
src = base_dir / screenshot_rel
|
|
612
|
+
if src.exists():
|
|
613
|
+
dest_name = src.name
|
|
614
|
+
dest = artifacts_dir / dest_name
|
|
615
|
+
shutil.copy2(src, dest)
|
|
616
|
+
print(f"[Reporting] ✓ Copied screenshot: {dest_name}")
|
|
617
|
+
# Update path to be relative to store root
|
|
618
|
+
test['screenshot_path'] = f"artifacts/{dest_name}"
|
|
619
|
+
artifact_count += 1
|
|
620
|
+
else:
|
|
621
|
+
print(f"[Reporting] ✗ Screenshot not found: {src}")
|
|
622
|
+
|
|
623
|
+
# Copy trace
|
|
624
|
+
if test.get('trace_path'):
|
|
625
|
+
# Normalize path separators
|
|
626
|
+
trace_rel = test['trace_path'].replace('\\', '/')
|
|
627
|
+
src = base_dir / trace_rel
|
|
628
|
+
if src.exists():
|
|
629
|
+
dest_name = src.name
|
|
630
|
+
dest = artifacts_dir / dest_name
|
|
631
|
+
shutil.copy2(src, dest)
|
|
632
|
+
print(f"[Reporting] ✓ Copied trace: {dest_name}")
|
|
633
|
+
test['trace_path'] = f"artifacts/{dest_name}"
|
|
634
|
+
artifact_count += 1
|
|
635
|
+
else:
|
|
636
|
+
print(f"[Reporting] ✗ Trace not found: {src}")
|
|
637
|
+
|
|
638
|
+
if artifact_count > 0:
|
|
639
|
+
print(f"[Reporting] Total artifacts copied: {artifact_count}")
|
|
640
|
+
|
|
574
641
|
def _deploy_static_local(self, templates_dir: Path):
|
|
575
642
|
import shutil
|
|
576
643
|
store = self.config.STORE_PATH
|
|
@@ -604,6 +671,9 @@ class Publisher:
|
|
|
604
671
|
bucket = self.config.S3_BUCKET
|
|
605
672
|
project = run_file['project_name']
|
|
606
673
|
|
|
674
|
+
# Upload artifacts first and update paths
|
|
675
|
+
self._copy_artifacts_s3(run_file, s3, bucket)
|
|
676
|
+
|
|
607
677
|
key = self._s3_key('runs', project, f"{run_id}.json")
|
|
608
678
|
s3.put_object(Bucket=bucket, Key=key,
|
|
609
679
|
Body=json.dumps(run_file, indent=2).encode('utf-8'),
|
|
@@ -640,6 +710,53 @@ class Publisher:
|
|
|
640
710
|
CacheControl='no-cache')
|
|
641
711
|
print(f"[Reporting] data.json updated: s3://{bucket}/{key}")
|
|
642
712
|
|
|
713
|
+
def _copy_artifacts_s3(self, run_file: Dict[str, Any], s3, bucket: str):
|
|
714
|
+
"""Upload screenshots and traces to S3 and update paths."""
|
|
715
|
+
from pathlib import Path as P
|
|
716
|
+
|
|
717
|
+
base_dir = P.cwd()
|
|
718
|
+
print(f"[Reporting] Base directory for artifacts: {base_dir}")
|
|
719
|
+
print(f"[Reporting] Artifacts will be uploaded to: s3://{bucket}/{self._s3_key('artifacts')}")
|
|
720
|
+
|
|
721
|
+
artifact_count = 0
|
|
722
|
+
|
|
723
|
+
# Process failed tests to upload their artifacts
|
|
724
|
+
for test in run_file.get('failed_tests', []):
|
|
725
|
+
# Upload screenshot
|
|
726
|
+
if test.get('screenshot_path'):
|
|
727
|
+
# Normalize path separators (important for cross-platform)
|
|
728
|
+
screenshot_rel = test['screenshot_path'].replace('\\', '/')
|
|
729
|
+
src = base_dir / screenshot_rel
|
|
730
|
+
if src.exists():
|
|
731
|
+
dest_key = self._s3_key('artifacts', src.name)
|
|
732
|
+
with open(src, 'rb') as f:
|
|
733
|
+
s3.put_object(Bucket=bucket, Key=dest_key, Body=f.read(),
|
|
734
|
+
ContentType='image/png')
|
|
735
|
+
print(f"[Reporting] ✓ Uploaded screenshot: {src.name}")
|
|
736
|
+
test['screenshot_path'] = f"artifacts/{src.name}"
|
|
737
|
+
artifact_count += 1
|
|
738
|
+
else:
|
|
739
|
+
print(f"[Reporting] ✗ Screenshot not found: {src}")
|
|
740
|
+
|
|
741
|
+
# Upload trace
|
|
742
|
+
if test.get('trace_path'):
|
|
743
|
+
# Normalize path separators (important for cross-platform)
|
|
744
|
+
trace_rel = test['trace_path'].replace('\\', '/')
|
|
745
|
+
src = base_dir / trace_rel
|
|
746
|
+
if src.exists():
|
|
747
|
+
dest_key = self._s3_key('artifacts', src.name)
|
|
748
|
+
with open(src, 'rb') as f:
|
|
749
|
+
s3.put_object(Bucket=bucket, Key=dest_key, Body=f.read(),
|
|
750
|
+
ContentType='application/zip')
|
|
751
|
+
print(f"[Reporting] ✓ Uploaded trace: {src.name}")
|
|
752
|
+
test['trace_path'] = f"artifacts/{src.name}"
|
|
753
|
+
artifact_count += 1
|
|
754
|
+
else:
|
|
755
|
+
print(f"[Reporting] ✗ Trace not found: {src}")
|
|
756
|
+
|
|
757
|
+
if artifact_count > 0:
|
|
758
|
+
print(f"[Reporting] Total artifacts uploaded to S3: {artifact_count}")
|
|
759
|
+
|
|
643
760
|
def _deploy_static_s3(self, templates_dir: Path):
|
|
644
761
|
s3 = self._get_s3_client()
|
|
645
762
|
bucket = self.config.S3_BUCKET
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
|
7
|
+
<meta http-equiv="Pragma" content="no-cache" />
|
|
8
|
+
<meta http-equiv="Expires" content="0" />
|
|
6
9
|
<title>Test Dashboard</title>
|
|
7
10
|
<style>
|
|
8
11
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@@ -131,6 +134,10 @@
|
|
|
131
134
|
return rate >= 95 ? 'rate-green' : rate >= 80 ? 'rate-yellow' : 'rate-red';
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
function plural(count, singular, pluralForm) {
|
|
138
|
+
return count !== 1 ? (pluralForm || singular + 's') : singular;
|
|
139
|
+
}
|
|
140
|
+
|
|
134
141
|
function render(data) {
|
|
135
142
|
const projects = Object.values(data.projects || {});
|
|
136
143
|
|
|
@@ -140,7 +147,6 @@
|
|
|
140
147
|
if (projects.length === 0) {
|
|
141
148
|
document.getElementById('root').innerHTML = `
|
|
142
149
|
<div class="empty">
|
|
143
|
-
<div class="empty-icon">📋</div>
|
|
144
150
|
<h3>No projects yet</h3>
|
|
145
151
|
<p>Run <code>test-report publish</code> from a project to see data here.</p>
|
|
146
152
|
</div>`;
|
|
@@ -178,7 +184,7 @@
|
|
|
178
184
|
</div>
|
|
179
185
|
<div class="stat">
|
|
180
186
|
<div class="stat-val">${suiteCount}</div>
|
|
181
|
-
<div class="stat-lbl">
|
|
187
|
+
<div class="stat-lbl">Test Files</div>
|
|
182
188
|
</div>
|
|
183
189
|
</div>
|
|
184
190
|
</a>`;
|
|
@@ -193,7 +199,6 @@
|
|
|
193
199
|
.catch(() => {
|
|
194
200
|
document.getElementById('root').innerHTML = `
|
|
195
201
|
<div class="empty">
|
|
196
|
-
<div class="empty-icon">⚠️</div>
|
|
197
202
|
<h3>Could not load data</h3>
|
|
198
203
|
<p>Make sure <code>test-report deploy</code> and <code>test-report publish</code> have been run.</p>
|
|
199
204
|
</div>`;
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
|
7
|
+
<meta http-equiv="Pragma" content="no-cache" />
|
|
8
|
+
<meta http-equiv="Expires" content="0" />
|
|
6
9
|
<title>Project — Test Dashboard</title>
|
|
7
10
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
8
11
|
<style>
|
|
@@ -196,6 +199,7 @@
|
|
|
196
199
|
function rateColor(r) { return r >= 95 ? 'green' : r >= 80 ? 'yellow' : 'red'; }
|
|
197
200
|
function barClass(r) { return r >= 95 ? 'bar-green' : r >= 80 ? 'bar-yellow' : 'bar-red'; }
|
|
198
201
|
function pillClass(r) { return r >= 95 ? 'pill-green' : r >= 80 ? 'pill-yellow' : 'pill-red'; }
|
|
202
|
+
function plural(count, singular, plural) { return count !== 1 ? (plural || singular + 's') : singular; }
|
|
199
203
|
|
|
200
204
|
/* ── Tab switching ── */
|
|
201
205
|
function switchTab(name, btn) {
|
|
@@ -218,6 +222,7 @@
|
|
|
218
222
|
═══════════════════════════════════════════════════ */
|
|
219
223
|
|
|
220
224
|
function renderOverview(proj) {
|
|
225
|
+
if (!proj) return '<p class="empty-section">No project data available.</p>';
|
|
221
226
|
const lastReg = Object.values(proj.regressions || {})[0];
|
|
222
227
|
const rate = proj.pass_rate || 0;
|
|
223
228
|
const rc = rateColor(rate);
|
|
@@ -241,7 +246,7 @@
|
|
|
241
246
|
</div>
|
|
242
247
|
<div class="metric">
|
|
243
248
|
<div class="metric-val">${Object.keys(proj.suites || {}).length}</div>
|
|
244
|
-
<div class="metric-lbl">
|
|
249
|
+
<div class="metric-lbl">Test Files</div>
|
|
245
250
|
<div class="metric-sub">Tracked</div>
|
|
246
251
|
</div>
|
|
247
252
|
</div>`;
|
|
@@ -251,7 +256,7 @@
|
|
|
251
256
|
? '<p class="empty-section">No regression data yet.</p>'
|
|
252
257
|
: regEntries.map((reg, i) => {
|
|
253
258
|
const rc2 = rateColor(reg.pass_rate);
|
|
254
|
-
const
|
|
259
|
+
const fileRows = (reg.suites || []).map(s => `
|
|
255
260
|
<div class="suite-row" onclick="goToRun('${s.run_id}')">
|
|
256
261
|
<div class="suite-name">${s.suite}</div>
|
|
257
262
|
<div class="suite-meta">${s.total_tests} tests · ${s.failed} failed</div>
|
|
@@ -264,19 +269,21 @@
|
|
|
264
269
|
</div>`).join('');
|
|
265
270
|
|
|
266
271
|
const isOpen = i === 0;
|
|
272
|
+
const chevronClass = isOpen ? 'chevron open' : 'chevron';
|
|
273
|
+
const bodyClass = isOpen ? 'reg-body open' : 'reg-body';
|
|
267
274
|
return `
|
|
268
275
|
<div class="regression">
|
|
269
276
|
<div class="reg-head" onclick="toggleReg(this)">
|
|
270
277
|
<div>
|
|
271
278
|
<div class="reg-title">${reg.tag}</div>
|
|
272
|
-
<div class="reg-sub">${reg.suites.length}
|
|
279
|
+
<div class="reg-sub">${reg.suites.length} test ${plural(reg.suites.length, 'file')} \u00b7 ${reg.total_tests} tests</div>
|
|
273
280
|
</div>
|
|
274
281
|
<div class="reg-right">
|
|
275
282
|
<span class="${rc2}">${reg.pass_rate.toFixed(0)}% passing</span>
|
|
276
|
-
<span class="
|
|
283
|
+
<span class="${chevronClass}"></span>
|
|
277
284
|
</div>
|
|
278
285
|
</div>
|
|
279
|
-
<div class="
|
|
286
|
+
<div class="${bodyClass}">${fileRows}</div>
|
|
280
287
|
</div>`;
|
|
281
288
|
}).join('');
|
|
282
289
|
|
|
@@ -284,7 +291,7 @@
|
|
|
284
291
|
<div class="section">
|
|
285
292
|
<div class="section-head">
|
|
286
293
|
<h3>Regressions</h3>
|
|
287
|
-
<p class="section-explain">Each group shows all
|
|
294
|
+
<p class="section-explain">Each group shows all test files that ran together on a given day or tag. Click a test file to see the full test breakdown.</p>
|
|
288
295
|
</div>
|
|
289
296
|
<div class="section-body">${regsHtml}</div>
|
|
290
297
|
</div>`;
|
|
@@ -316,7 +323,9 @@
|
|
|
316
323
|
<p class="section-explain">Shows what percentage of tests passed each day. An upward trend means quality is improving. A downward trend is a warning sign that needs attention.</p>
|
|
317
324
|
</div>
|
|
318
325
|
<div class="section-body">
|
|
319
|
-
|
|
326
|
+
${(a.pass_rate_trend || []).length === 0
|
|
327
|
+
? '<p class="empty-section">No trend data yet. Run more tests to see quality trends over time.</p>'
|
|
328
|
+
: `<div class="chart-wrap chart-wrap-md"><canvas id="trendChart"></canvas></div>`}
|
|
320
329
|
</div>
|
|
321
330
|
</div>
|
|
322
331
|
|
|
@@ -477,34 +486,38 @@
|
|
|
477
486
|
HISTORY TAB
|
|
478
487
|
═══════════════════════════════════════════════════ */
|
|
479
488
|
|
|
480
|
-
let activeSuite = null;
|
|
481
|
-
let _suitesCache = {}; // module-level store so onclick handlers don't inline JSON
|
|
482
|
-
|
|
483
489
|
function renderHistory(suites) {
|
|
484
|
-
|
|
490
|
+
if (!suites || typeof suites !== 'object') {
|
|
491
|
+
return '<p class="empty-section">No run history yet.</p>';
|
|
492
|
+
}
|
|
485
493
|
const names = Object.keys(suites);
|
|
486
494
|
if (names.length === 0) return '<p class="empty-section">No run history yet.</p>';
|
|
487
|
-
if (!activeSuite || !suites[activeSuite]) activeSuite = names[0];
|
|
488
495
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
496
|
+
// Collect all runs from all test files
|
|
497
|
+
const allRuns = [];
|
|
498
|
+
for (const [fileName, suite] of Object.entries(suites)) {
|
|
499
|
+
for (const run of (suite.runs || [])) {
|
|
500
|
+
allRuns.push({
|
|
501
|
+
...run,
|
|
502
|
+
file_name: fileName
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
494
506
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
507
|
+
// Sort by timestamp descending (newest first)
|
|
508
|
+
allRuns.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
|
|
509
|
+
|
|
510
|
+
// Take the most recent runs (limit to avoid overwhelming the UI)
|
|
511
|
+
const recentRuns = allRuns.slice(0, 100);
|
|
512
|
+
|
|
513
|
+
if (recentRuns.length === 0) return '<p class="empty-section">No runs yet.</p>';
|
|
499
514
|
|
|
500
|
-
|
|
501
|
-
const runs = (suite.runs || []).slice(0, 30);
|
|
502
|
-
if (runs.length === 0) return '<p class="empty-section">No runs yet.</p>';
|
|
503
|
-
const rows = runs.map(run => {
|
|
515
|
+
const rows = recentRuns.map(run => {
|
|
504
516
|
const pc = pillClass(run.pass_rate);
|
|
505
517
|
return `
|
|
506
518
|
<tr>
|
|
507
519
|
<td>${fmtDate(run.timestamp)}</td>
|
|
520
|
+
<td><div style="font-weight:600;font-size:13px">${run.file_name}</div></td>
|
|
508
521
|
<td>${run.regression_tag || '—'}</td>
|
|
509
522
|
<td><span class="pill ${pc}">${run.pass_rate.toFixed(0)}%</span></td>
|
|
510
523
|
<td>${run.total_tests}</td>
|
|
@@ -513,12 +526,17 @@
|
|
|
513
526
|
<td><a href="run.html?r=${encodeURIComponent(run.run_id)}&p=${encodeURIComponent(projectName)}">View →</a></td>
|
|
514
527
|
</tr>`;
|
|
515
528
|
}).join('');
|
|
529
|
+
|
|
516
530
|
return `
|
|
517
531
|
<div class="section">
|
|
532
|
+
<div class="section-head">
|
|
533
|
+
<h3>All Test File Runs</h3>
|
|
534
|
+
<span class="count">${recentRuns.length} recent runs across ${names.length} test ${plural(names.length, 'file')}</span>
|
|
535
|
+
</div>
|
|
518
536
|
<div class="section-body-flush">
|
|
519
537
|
<table class="hist-table">
|
|
520
538
|
<thead><tr>
|
|
521
|
-
<th>Date</th><th>Tag</th><th>Pass Rate</th>
|
|
539
|
+
<th>Date</th><th>Test File</th><th>Tag</th><th>Pass Rate</th>
|
|
522
540
|
<th>Tests</th><th>Failed</th><th>Duration</th><th></th>
|
|
523
541
|
</tr></thead>
|
|
524
542
|
<tbody>${rows}</tbody>
|
|
@@ -527,23 +545,17 @@
|
|
|
527
545
|
</div>`;
|
|
528
546
|
}
|
|
529
547
|
|
|
530
|
-
function switchSuiteTab(name, btn) {
|
|
531
|
-
activeSuite = name;
|
|
532
|
-
btn.closest('.tab-bar').querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
533
|
-
btn.classList.add('active');
|
|
534
|
-
document.getElementById('suiteHistoryBody').innerHTML = renderSuiteRuns(_suitesCache[name]);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
548
|
/* ═══════════════════════════════════════════════════
|
|
538
549
|
CHART RENDERERS (called after DOM is ready)
|
|
539
550
|
═══════════════════════════════════════════════════ */
|
|
540
551
|
|
|
541
552
|
function drawCharts(analytics) {
|
|
542
|
-
|
|
553
|
+
if (!analytics || typeof analytics !== 'object') return;
|
|
554
|
+
const a = analytics;
|
|
543
555
|
|
|
544
556
|
// Trend chart
|
|
545
557
|
const trend = a.pass_rate_trend || [];
|
|
546
|
-
if (trend.length >=
|
|
558
|
+
if (trend.length >= 1) {
|
|
547
559
|
makeChart('trendChart', {
|
|
548
560
|
type: 'line',
|
|
549
561
|
data: {
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
|
7
|
+
<meta http-equiv="Pragma" content="no-cache" />
|
|
8
|
+
<meta http-equiv="Expires" content="0" />
|
|
6
9
|
<title>Run Details — Test Dashboard</title>
|
|
7
10
|
<style>
|
|
8
11
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@@ -111,9 +114,9 @@
|
|
|
111
114
|
.passed-toggle:hover { color: var(--text); }
|
|
112
115
|
.passed-list { display: none; }
|
|
113
116
|
.passed-list.open { display: block; }
|
|
114
|
-
.passed-
|
|
115
|
-
|
|
116
|
-
.passed-
|
|
117
|
+
.passed-card { border-bottom: 1px solid var(--border); padding: 16px 24px; }
|
|
118
|
+
.passed-card:last-child { border-bottom: none; }
|
|
119
|
+
.passed-header { display: flex; align-items: flex-start; gap: 8px; }
|
|
117
120
|
|
|
118
121
|
/* ── Build info banner ── */
|
|
119
122
|
.build-banner { background: var(--blue-bg); border: 1px solid #bee3f8;
|
|
@@ -161,6 +164,7 @@
|
|
|
161
164
|
|
|
162
165
|
function rateColor(r) { return r >= 95 ? 'green' : r >= 80 ? 'yellow' : 'red'; }
|
|
163
166
|
function barClass(r) { return r >= 95 ? 'bar-green' : r >= 80 ? 'bar-yellow' : 'bar-red'; }
|
|
167
|
+
function plural(count, singular, pluralForm) { return count !== 1 ? (pluralForm || singular + 's') : singular; }
|
|
164
168
|
|
|
165
169
|
function failureTag(type) {
|
|
166
170
|
const map = {
|
|
@@ -175,9 +179,13 @@
|
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
function renderRun(run) {
|
|
178
|
-
|
|
182
|
+
if (!run || typeof run !== 'object') {
|
|
183
|
+
document.getElementById('root').innerHTML = '<div class="loading">Invalid run data.</div>';
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
document.title = `${run.suite_name || 'Unknown'} — Test Dashboard`;
|
|
179
187
|
document.getElementById('pageTitle').textContent =
|
|
180
|
-
|
|
188
|
+
`Test File: ${run.suite_name || 'Unknown'} · ${run.project_name || 'Unknown'}`;
|
|
181
189
|
document.getElementById('runTime').textContent = fmtDate(run.timestamp);
|
|
182
190
|
|
|
183
191
|
const backHref = projectName
|
|
@@ -228,7 +236,7 @@
|
|
|
228
236
|
// Test files section
|
|
229
237
|
const fileRows = (run.test_files || []).map(f => {
|
|
230
238
|
const fr = rateColor(f.pass_rate);
|
|
231
|
-
const icon = f.failed > 0 ? '
|
|
239
|
+
const icon = f.failed > 0 ? 'X' : '✓';
|
|
232
240
|
const iconColor = f.failed > 0 ? 'color:var(--red)' : 'color:var(--green)';
|
|
233
241
|
return `
|
|
234
242
|
<div class="file-row">
|
|
@@ -261,8 +269,9 @@
|
|
|
261
269
|
if (failed.length > 0) {
|
|
262
270
|
const cards = failed.map(t => {
|
|
263
271
|
const actions = [
|
|
264
|
-
t.screenshot_path ? `<a class="btn-link" href="${t.screenshot_path}" target="_blank"
|
|
265
|
-
t.trace_path ? `<a class="btn-link" href="${t.trace_path}"
|
|
272
|
+
t.screenshot_path ? `<a class="btn-link" href="${t.screenshot_path}" target="_blank">Screenshot</a>` : '',
|
|
273
|
+
t.trace_path ? `<a class="btn-link" href="${t.trace_path}" download title="Download trace file. View with: npx playwright show-trace [filename]">Download Trace</a>` : '',
|
|
274
|
+
t.logs ? `<button class="btn-link" onclick="toggleLogs(this, '${btoa(t.logs || '').replace(/'/g, "\\'")}')">View Logs</button>` : '',
|
|
266
275
|
].filter(Boolean).join('');
|
|
267
276
|
|
|
268
277
|
const errPreview = t.error_message
|
|
@@ -280,6 +289,7 @@
|
|
|
280
289
|
</div>
|
|
281
290
|
${errPreview ? `<div class="fail-error">${errPreview.replace(/</g,'<').replace(/>/g,'>')}</div>` : ''}
|
|
282
291
|
${actions ? `<div class="fail-actions">${actions}</div>` : ''}
|
|
292
|
+
<div class="test-logs" style="display:none;"></div>
|
|
283
293
|
</div>`;
|
|
284
294
|
}).join('');
|
|
285
295
|
|
|
@@ -287,29 +297,34 @@
|
|
|
287
297
|
<div class="section">
|
|
288
298
|
<div class="section-head">
|
|
289
299
|
<h3>What Failed</h3>
|
|
290
|
-
<span class="count">${failed.length}
|
|
300
|
+
<span class="count">${failed.length} ${plural(failed.length, 'test')}</span>
|
|
291
301
|
</div>
|
|
292
302
|
<div class="section-body">${cards}</div>
|
|
293
303
|
</div>`;
|
|
294
304
|
}
|
|
295
305
|
|
|
296
306
|
// Passed tests (collapsed by default)
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
);
|
|
300
|
-
// We only have file-level passed counts, not individual test names in the summary
|
|
301
|
-
// Individual names would require loading per-run detail — show file summary instead
|
|
302
|
-
const passedSection = run.passed > 0 ? `
|
|
307
|
+
const passedTests = run.passed_tests || [];
|
|
308
|
+
const passedSection = passedTests.length > 0 ? `
|
|
303
309
|
<div class="section">
|
|
304
310
|
<div class="passed-toggle" onclick="togglePassed(this)">
|
|
305
|
-
▶ Show passing
|
|
311
|
+
▶ Show passing tests (${passedTests.length} ${plural(passedTests.length, 'test')})
|
|
306
312
|
</div>
|
|
307
313
|
<div class="passed-list" id="passedList">
|
|
308
|
-
${
|
|
309
|
-
<div class="passed-
|
|
310
|
-
<
|
|
311
|
-
|
|
312
|
-
|
|
314
|
+
${passedTests.map(t => `
|
|
315
|
+
<div class="passed-card">
|
|
316
|
+
<div class="passed-header">
|
|
317
|
+
<span style="color:var(--green);margin-right:8px;">✓</span>
|
|
318
|
+
<div style="flex:1;">
|
|
319
|
+
<div style="font-weight:600;font-size:14px;">${t.test_name}</div>
|
|
320
|
+
<div style="font-size:12px;color:var(--muted);margin-top:2px;">${t.file_name} · ${fmtDur(t.duration_seconds)}</div>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
${t.logs ? `
|
|
324
|
+
<div style="margin-top:8px;">
|
|
325
|
+
<button class="btn-link" onclick="toggleLogs(this, '${btoa(t.logs || '').replace(/'/g, "\\'")}')">View Logs</button>
|
|
326
|
+
<div class="test-logs" style="display:none;"></div>
|
|
327
|
+
</div>` : ''}
|
|
313
328
|
</div>`).join('')}
|
|
314
329
|
</div>
|
|
315
330
|
</div>` : '';
|
|
@@ -321,9 +336,29 @@
|
|
|
321
336
|
function togglePassed(el) {
|
|
322
337
|
const list = document.getElementById('passedList');
|
|
323
338
|
const open = list.classList.toggle('open');
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
339
|
+
const arrow = open ? '▼' : '▶';
|
|
340
|
+
const action = open ? 'Hide' : 'Show';
|
|
341
|
+
const count = document.querySelectorAll('.passed-card').length;
|
|
342
|
+
el.textContent = `${arrow} ${action} passing tests (${count} ${plural(count, 'test')})`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function toggleLogs(btn, encodedLogs) {
|
|
346
|
+
const card = btn.closest('.fail-card') || btn.closest('.passed-card');
|
|
347
|
+
const logsDiv = card.querySelector('.test-logs');
|
|
348
|
+
if (logsDiv.style.display === 'none') {
|
|
349
|
+
try {
|
|
350
|
+
const logs = atob(encodedLogs);
|
|
351
|
+
logsDiv.innerHTML = `<div class="fail-error" style="margin-top:10px;max-height:300px;overflow-y:auto;">${logs.replace(/</g,'<').replace(/>/g,'>')}</div>`;
|
|
352
|
+
logsDiv.style.display = 'block';
|
|
353
|
+
btn.textContent = 'Hide Logs';
|
|
354
|
+
} catch(e) {
|
|
355
|
+
logsDiv.innerHTML = '<p style="color:var(--muted);margin-top:10px;">No logs available</p>';
|
|
356
|
+
logsDiv.style.display = 'block';
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
logsDiv.style.display = 'none';
|
|
360
|
+
btn.textContent = 'View Logs';
|
|
361
|
+
}
|
|
327
362
|
}
|
|
328
363
|
|
|
329
364
|
// Load the per-run detail file directly
|
|
@@ -9,7 +9,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
|
9
9
|
|
|
10
10
|
setup(
|
|
11
11
|
name='test-reporting',
|
|
12
|
-
version='3.0
|
|
12
|
+
version='3.1.0',
|
|
13
13
|
description='Multi-project test reporting dashboard — collect results locally or push to S3',
|
|
14
14
|
long_description=long_description,
|
|
15
15
|
long_description_content_type="text/markdown",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|