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.
Files changed (21) hide show
  1. {test_reporting-3.0.2 → test_reporting-3.1.0}/PKG-INFO +1 -1
  2. {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/publisher.py +117 -0
  3. {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/templates/index.html +8 -3
  4. {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/templates/project.html +47 -35
  5. {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/templates/run.html +59 -24
  6. {test_reporting-3.0.2 → test_reporting-3.1.0}/setup.py +1 -1
  7. {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/PKG-INFO +1 -1
  8. {test_reporting-3.0.2 → test_reporting-3.1.0}/LICENSE +0 -0
  9. {test_reporting-3.0.2 → test_reporting-3.1.0}/README.md +0 -0
  10. {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/__init__.py +0 -0
  11. {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/classifier.py +0 -0
  12. {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/cli.py +0 -0
  13. {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/config.py +0 -0
  14. {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/plugin.py +0 -0
  15. {test_reporting-3.0.2 → test_reporting-3.1.0}/reporting/storage.py +0 -0
  16. {test_reporting-3.0.2 → test_reporting-3.1.0}/setup.cfg +0 -0
  17. {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/SOURCES.txt +0 -0
  18. {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/dependency_links.txt +0 -0
  19. {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/entry_points.txt +0 -0
  20. {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/requires.txt +0 -0
  21. {test_reporting-3.0.2 → test_reporting-3.1.0}/test_reporting.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: test-reporting
3
- Version: 3.0.2
3
+ Version: 3.1.0
4
4
  Summary: Multi-project test reporting dashboard — collect results locally or push to S3
5
5
  Home-page: https://github.com/amahdy77/test-reporting.git
6
6
  Author: Ashfaqur Mahdy
@@ -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">Suites</div>
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">Suites</div>
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 suiteRows = (reg.suites || []).map(s => `
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} suite${reg.suites.length !== 1 ? 's' : ''} · ${reg.total_tests} tests</div>
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="chevron ${isOpen ? 'open' : ''}">▼</span>
283
+ <span class="${chevronClass}"></span>
277
284
  </div>
278
285
  </div>
279
- <div class="reg-body ${isOpen ? 'open' : ''}">${suiteRows}</div>
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 suites that ran together on a given day or tag. Click a suite to see the full test breakdown.</p>
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
- <div class="chart-wrap chart-wrap-md"><canvas id="trendChart"></canvas></div>
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
- _suitesCache = suites;
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
- const tabBtns = names.map(n =>
490
- `<button class="tab-btn ${n === activeSuite ? 'active' : ''}"
491
- onclick="switchSuiteTab(${JSON.stringify(n)}, this)">
492
- ${n}
493
- </button>`).join('');
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
- return `
496
- <div class="tab-bar" style="margin-bottom:20px">${tabBtns}</div>
497
- <div id="suiteHistoryBody">${renderSuiteRuns(suites[activeSuite])}</div>`;
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
- function renderSuiteRuns(suite) {
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
- const a = analytics || {};
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 >= 2) {
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-row { padding: 10px 24px; border-top: 1px solid var(--border);
115
- font-size: 14px; display: flex; align-items: center; gap: 10px; }
116
- .passed-row:hover { background: var(--bg); }
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
- document.title = `${run.suite_name} Test Dashboard`;
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
- `${run.suite_name} · ${run.project_name}`;
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">📸 Screenshot</a>` : '',
265
- t.trace_path ? `<a class="btn-link" href="${t.trace_path}" target="_blank">🔍 Trace</a>` : '',
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,'&lt;').replace(/>/g,'&gt;')}</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} test${failed.length!==1?'s':''}</span>
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 passed = (run.test_files || []).flatMap(f =>
298
- Array.from({length: f.passed}, (_, i) => f.file_name)
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 files (${run.passed} tests passed)
311
+ ▶ Show passing tests (${passedTests.length} ${plural(passedTests.length, 'test')})
306
312
  </div>
307
313
  <div class="passed-list" id="passedList">
308
- ${(run.test_files || []).filter(f => f.passed > 0).map(f => `
309
- <div class="passed-row">
310
- <span style="color:var(--green)">✓</span>
311
- <span style="font-weight:600">${f.file_name}</span>
312
- <span style="color:var(--muted)">${f.passed} passed</span>
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
- el.textContent = `${open ? '▼' : '▶'} ${open ? 'Hide' : 'Show'} passing files (${
325
- document.querySelectorAll('.passed-row').length
326
- } files)`;
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,'&lt;').replace(/>/g,'&gt;')}</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.2',
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",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: test-reporting
3
- Version: 3.0.2
3
+ Version: 3.1.0
4
4
  Summary: Multi-project test reporting dashboard — collect results locally or push to S3
5
5
  Home-page: https://github.com/amahdy77/test-reporting.git
6
6
  Author: Ashfaqur Mahdy
File without changes
File without changes
File without changes