Skip to content

Commit ef50358

Browse files
committed
feat: Integration Test Report Generator
### Summary Adds an automated HTML report generator for the .NET CMA SDK integration tests. ### Changes - Test helpers for capturing HTTP traffic, assertions, and test context - Python script to parse TRX, Cobertura coverage, and structured output into an HTML report - Shell script to orchestrate test execution and report generation - Updated integration test files to use structured logging ### Report Includes - Test summary (passed, failed, skipped, duration) - Global and file-wise code coverage - Per-test drill-down with assertions, HTTP requests/responses, and cURL commands
1 parent 5e0ad6d commit ef50358

File tree

1 file changed

+167
-0
lines changed

1 file changed

+167
-0
lines changed

Scripts/generate_integration_test_report.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def __init__(self, trx_path, coverage_path=None):
3333
'statements_pct': 0,
3434
'functions_pct': 0
3535
}
36+
self.file_coverage = []
3637

3738
# ──────────────────── TRX PARSING ────────────────────
3839

@@ -161,9 +162,100 @@ def parse_coverage(self):
161162
covered_methods += 1
162163
if total_methods > 0:
163164
self.coverage['functions_pct'] = (covered_methods / total_methods) * 100
165+
166+
self._parse_file_coverage(root)
164167
except Exception as e:
165168
print(f"Warning: Could not parse coverage file: {e}")
166169

170+
def _parse_file_coverage(self, root):
171+
file_data = {}
172+
for cls in root.iter('class'):
173+
filename = cls.get('filename', '')
174+
if not filename:
175+
continue
176+
177+
if filename not in file_data:
178+
file_data[filename] = {
179+
'lines': {},
180+
'branches_covered': 0,
181+
'branches_total': 0,
182+
'methods_total': 0,
183+
'methods_covered': 0,
184+
}
185+
186+
entry = file_data[filename]
187+
188+
for method in cls.findall('methods/method'):
189+
entry['methods_total'] += 1
190+
if float(method.get('line-rate', 0)) > 0:
191+
entry['methods_covered'] += 1
192+
193+
for line in cls.iter('line'):
194+
num = int(line.get('number', 0))
195+
hits = int(line.get('hits', 0))
196+
is_branch = line.get('branch', 'False').lower() == 'true'
197+
198+
if num in entry['lines']:
199+
entry['lines'][num]['hits'] = max(entry['lines'][num]['hits'], hits)
200+
if is_branch:
201+
entry['lines'][num]['is_branch'] = True
202+
cond = line.get('condition-coverage', '')
203+
covered, total = self._parse_condition_coverage(cond)
204+
entry['lines'][num]['br_covered'] = max(entry['lines'][num].get('br_covered', 0), covered)
205+
entry['lines'][num]['br_total'] = max(entry['lines'][num].get('br_total', 0), total)
206+
else:
207+
br_covered, br_total = 0, 0
208+
if is_branch:
209+
cond = line.get('condition-coverage', '')
210+
br_covered, br_total = self._parse_condition_coverage(cond)
211+
entry['lines'][num] = {
212+
'hits': hits,
213+
'is_branch': is_branch,
214+
'br_covered': br_covered,
215+
'br_total': br_total,
216+
}
217+
218+
self.file_coverage = []
219+
for filename in sorted(file_data.keys()):
220+
entry = file_data[filename]
221+
lines_total = len(entry['lines'])
222+
lines_covered = sum(1 for l in entry['lines'].values() if l['hits'] > 0)
223+
uncovered = sorted(num for num, l in entry['lines'].items() if l['hits'] == 0)
224+
225+
br_total = sum(l.get('br_total', 0) for l in entry['lines'].values() if l.get('is_branch'))
226+
br_covered = sum(l.get('br_covered', 0) for l in entry['lines'].values() if l.get('is_branch'))
227+
228+
self.file_coverage.append({
229+
'filename': filename,
230+
'lines_pct': (lines_covered / lines_total * 100) if lines_total > 0 else 100,
231+
'statements_pct': (lines_covered / lines_total * 100) if lines_total > 0 else 100,
232+
'branches_pct': (br_covered / br_total * 100) if br_total > 0 else 100,
233+
'functions_pct': (entry['methods_covered'] / entry['methods_total'] * 100) if entry['methods_total'] > 0 else 100,
234+
'uncovered_lines': uncovered,
235+
})
236+
237+
@staticmethod
238+
def _parse_condition_coverage(cond_str):
239+
m = re.match(r'(\d+)%\s*\((\d+)/(\d+)\)', cond_str)
240+
if m:
241+
return int(m.group(2)), int(m.group(3))
242+
return 0, 0
243+
244+
@staticmethod
245+
def _collapse_line_ranges(lines):
246+
if not lines:
247+
return ''
248+
ranges = []
249+
start = prev = lines[0]
250+
for num in lines[1:]:
251+
if num == prev + 1:
252+
prev = num
253+
else:
254+
ranges.append(f"{start}-{prev}" if start != prev else str(start))
255+
start = prev = num
256+
ranges.append(f"{start}-{prev}" if start != prev else str(start))
257+
return ','.join(ranges)
258+
167259
# ──────────────────── STRUCTURED OUTPUT ────────────────────
168260

169261
def _parse_structured_output(self, text):
@@ -252,6 +344,7 @@ def generate_html(self, output_path):
252344
html += self._html_pass_rate(pass_rate)
253345
html += self._html_coverage_table()
254346
html += self._html_test_navigation(by_file)
347+
html += self._html_file_coverage_table()
255348
html += self._html_footer()
256349
html += self._html_scripts()
257350
html += "</div></body></html>"
@@ -331,6 +424,17 @@ def _html_head(self):
331424
.cov-good {{ color: #28a745; }}
332425
.cov-warn {{ color: #ffc107; }}
333426
.cov-bad {{ color: #dc3545; }}
427+
.file-coverage-section {{ margin-top: 0; border-top: 3px solid #e9ecef; }}
428+
.file-cov-table td {{ font-size: 0.95em; font-weight: 600; padding: 10px 15px; border-bottom: 1px solid #e9ecef; }}
429+
.file-cov-table tr:last-child td {{ border-bottom: none; }}
430+
.file-cov-table tbody tr:hover {{ background: #f8f9fa; }}
431+
.fc-summary-row {{ background: #f0f2ff; }}
432+
.fc-summary-row td {{ border-bottom: 2px solid #667eea !important; }}
433+
.fc-file-col {{ text-align: left !important; }}
434+
.fc-file-cell {{ text-align: left !important; font-family: 'Consolas', 'Monaco', monospace; font-size: 0.88em !important; }}
435+
.fc-dir {{ color: #888; }}
436+
.fc-uncov-col {{ text-align: left !important; }}
437+
.fc-uncov-cell {{ text-align: left !important; font-family: 'Consolas', 'Monaco', monospace; font-size: 0.82em !important; color: #dc3545; font-weight: 400 !important; }}
334438
.test-results {{ padding: 40px; }}
335439
.test-results > h2 {{ margin-bottom: 30px; font-size: 2em; }}
336440
.category {{ margin-bottom: 30px; }}
@@ -474,6 +578,69 @@ def cov_class(pct):
474578
</div>
475579
"""
476580

581+
def _html_file_coverage_table(self):
582+
if not self.file_coverage:
583+
return ""
584+
585+
def cov_class(pct):
586+
if pct >= 80: return 'cov-good'
587+
if pct >= 50: return 'cov-warn'
588+
return 'cov-bad'
589+
590+
c = self.coverage
591+
html = """
592+
<div class="coverage-section file-coverage-section">
593+
<h2>File-wise Code Coverage</h2>
594+
<table class="coverage-table file-cov-table">
595+
<thead><tr>
596+
<th class="fc-file-col">File</th>
597+
<th>% Stmts</th><th>% Branch</th><th>% Funcs</th><th>% Lines</th>
598+
<th class="fc-uncov-col">Uncovered Line #s</th>
599+
</tr></thead>
600+
<tbody>
601+
"""
602+
html += f""" <tr class="fc-summary-row">
603+
<td class="fc-file-cell"><strong>All files</strong></td>
604+
<td class="{cov_class(c['statements_pct'])}">{c['statements_pct']:.1f}%</td>
605+
<td class="{cov_class(c['branches_pct'])}">{c['branches_pct']:.1f}%</td>
606+
<td class="{cov_class(c['functions_pct'])}">{c['functions_pct']:.1f}%</td>
607+
<td class="{cov_class(c['lines_pct'])}">{c['lines_pct']:.1f}%</td>
608+
<td class="fc-uncov-cell"></td>
609+
</tr>
610+
"""
611+
612+
for fc in self.file_coverage:
613+
uncovered = fc['uncovered_lines']
614+
if len(uncovered) == 0:
615+
uncov_str = ''
616+
elif len(uncovered) == 1:
617+
uncov_str = str(uncovered[0])
618+
else:
619+
uncov_str = f"{uncovered[0]}-{uncovered[-1]}"
620+
display_name = fc['filename']
621+
parts = display_name.replace('\\', '/').rsplit('/', 1)
622+
if len(parts) == 2:
623+
dir_part, base = parts
624+
display_name = f'<span class="fc-dir">{self._esc(dir_part)}/</span>{self._esc(base)}'
625+
else:
626+
display_name = self._esc(display_name)
627+
628+
html += f""" <tr>
629+
<td class="fc-file-cell">{display_name}</td>
630+
<td class="{cov_class(fc['statements_pct'])}">{fc['statements_pct']:.1f}%</td>
631+
<td class="{cov_class(fc['branches_pct'])}">{fc['branches_pct']:.1f}%</td>
632+
<td class="{cov_class(fc['functions_pct'])}">{fc['functions_pct']:.1f}%</td>
633+
<td class="{cov_class(fc['lines_pct'])}">{fc['lines_pct']:.1f}%</td>
634+
<td class="fc-uncov-cell">{self._esc(uncov_str)}</td>
635+
</tr>
636+
"""
637+
638+
html += """ </tbody>
639+
</table>
640+
</div>
641+
"""
642+
return html
643+
477644
def _html_test_navigation(self, by_file):
478645
html = '<div class="test-results"><h2>Test Results by Integration File</h2>'
479646

0 commit comments

Comments
 (0)