@freshworks/shiftleft-tools 1.1.8

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 (106) hide show
  1. package/README.md +351 -0
  2. package/bin/shiftleft.js +95 -0
  3. package/package.json +57 -0
  4. package/src/commands/doctor.js +208 -0
  5. package/src/commands/init-postman.js +298 -0
  6. package/src/commands/init-rules.js +78 -0
  7. package/src/commands/link.js +172 -0
  8. package/src/commands/protect.js +61 -0
  9. package/src/commands/run-tests.js +182 -0
  10. package/src/commands/setup-pipeline.js +209 -0
  11. package/src/commands/update.js +203 -0
  12. package/src/index.js +4 -0
  13. package/src/utils/copy-tree.js +98 -0
  14. package/src/utils/gitignore.js +26 -0
  15. package/src/utils/logger.js +9 -0
  16. package/src/utils/manifest.js +145 -0
  17. package/src/utils/stack.js +80 -0
  18. package/src/utils/template.js +135 -0
  19. package/templates/AGENTS.md +109 -0
  20. package/templates/CLAUDE.md +3 -0
  21. package/templates/jenkins/Jenkinsfile-java.groovy +432 -0
  22. package/templates/jenkins/Jenkinsfile-node.groovy +450 -0
  23. package/templates/postman/.husky/pre-commit +19 -0
  24. package/templates/postman/.prettierrc.json +5 -0
  25. package/templates/postman/README.md.ejs +147 -0
  26. package/templates/postman/collections/01-core.json.ejs +91 -0
  27. package/templates/postman/config/local.json.ejs +12 -0
  28. package/templates/postman/config/staging.json.ejs +26 -0
  29. package/templates/postman/environments/local.postman_environment.json.ejs +31 -0
  30. package/templates/postman/environments/staging.postman_environment.json.ejs +31 -0
  31. package/templates/postman/gitignore +16 -0
  32. package/templates/postman/npmrc +31 -0
  33. package/templates/postman/package.json.ejs +66 -0
  34. package/templates/postman/run-all-shim.sh +16 -0
  35. package/templates/postman/scripts/auth/generate-jwt.sh +113 -0
  36. package/templates/postman/scripts/auth/get-issuer-secret.sh +140 -0
  37. package/templates/postman/scripts/infra/start-mocks.sh +138 -0
  38. package/templates/postman/scripts/infra/stop-mocks.sh +43 -0
  39. package/templates/postman/scripts/lib/api_coverage.py +1122 -0
  40. package/templates/postman/scripts/lib/cleanup-reports.sh +101 -0
  41. package/templates/postman/scripts/lib/cleanup-stryker.sh +44 -0
  42. package/templates/postman/scripts/lib/report_combined.py +527 -0
  43. package/templates/postman/scripts/lib/report_consolidated.py +363 -0
  44. package/templates/postman/scripts/lib/report_generator.py +121 -0
  45. package/templates/postman/scripts/lib/report_migration.py +156 -0
  46. package/templates/postman/scripts/lib/report_mutation.py +110 -0
  47. package/templates/postman/scripts/lib/report_unit.py +353 -0
  48. package/templates/postman/scripts/lib/report_utils.py +973 -0
  49. package/templates/postman/scripts/report-generators/generate-consolidated-report.sh +445 -0
  50. package/templates/postman/scripts/report-generators/java-api-coverage-matrix.sh +257 -0
  51. package/templates/postman/scripts/report-generators/mutation-report.sh +672 -0
  52. package/templates/postman/scripts/report-generators/node-api-coverage-matrix.sh +167 -0
  53. package/templates/postman/scripts/report-generators/stage-report-artifacts.sh +27 -0
  54. package/templates/postman/scripts/run-all.sh +452 -0
  55. package/templates/postman/scripts/runners/run-mutation-tests.sh +113 -0
  56. package/templates/postman/scripts/runners/run-tests-local.sh +936 -0
  57. package/templates/postman/scripts/runners/run-tests-staging.sh +741 -0
  58. package/templates/postman-node/README.md.ejs +26 -0
  59. package/templates/postman-node/collections/crud/01-bootstrap.json.ejs +34 -0
  60. package/templates/postman-node/config/local.json.ejs +46 -0
  61. package/templates/postman-node/config/staging.json.ejs +31 -0
  62. package/templates/postman-node/local.test.env.ejs +3 -0
  63. package/templates/postman-node/mocks/external.js +14 -0
  64. package/templates/postman-node/package.json.ejs +39 -0
  65. package/templates/postman-node/requirements.txt +1 -0
  66. package/templates/postman-node/scripts/database/cleanup-mysql.sh +12 -0
  67. package/templates/postman-node/scripts/database/run-migrations.js +29 -0
  68. package/templates/postman-node/scripts/database/start-mysql.sh +34 -0
  69. package/templates/postman-node/scripts/database/wait-for-mysql.sh +36 -0
  70. package/templates/postman-node/scripts/lib/api_coverage_node.py +1137 -0
  71. package/templates/postman-node/scripts/lib/fetch-jwt.sh +86 -0
  72. package/templates/postman-node/scripts/lib/run-newman.sh +104 -0
  73. package/templates/postman-node/scripts/lib/setup-database.sh +55 -0
  74. package/templates/postman-node/scripts/lib/start-app.sh +48 -0
  75. package/templates/postman-node/scripts/lib/utils.sh +114 -0
  76. package/templates/postman-node/scripts/report-generators/stage-report-artifacts.sh +26 -0
  77. package/templates/postman-node/scripts/run-all.sh +303 -0
  78. package/templates/postman-node/scripts/runners/run-tests.sh +123 -0
  79. package/templates/postman-node/scripts/setup-mocks.js.ejs +29 -0
  80. package/templates/postman-node/stryker.config.js.ejs +51 -0
  81. package/templates/rules/local-test-setup.mdc +420 -0
  82. package/templates/rules/testing-node.mdc +66 -0
  83. package/templates/rules/testing.mdc +248 -0
  84. package/templates/skills/_shared/postman-standards.md +380 -0
  85. package/templates/skills/enhance-test-pipeline/SKILL-java.md +483 -0
  86. package/templates/skills/enhance-test-pipeline/SKILL-node.md +431 -0
  87. package/templates/skills/enhance-test-pipeline/SKILL.md +9 -0
  88. package/templates/skills/review-test-suite/SKILL-java.md +137 -0
  89. package/templates/skills/review-test-suite/SKILL-node.md +78 -0
  90. package/templates/skills/review-test-suite/SKILL.md +9 -0
  91. package/templates/skills/run-test-suite/SKILL-java.md +186 -0
  92. package/templates/skills/run-test-suite/SKILL-node.md +191 -0
  93. package/templates/skills/run-test-suite/SKILL.md +9 -0
  94. package/templates/skills/setup-api-tests/SKILL-java.md +1094 -0
  95. package/templates/skills/setup-api-tests/SKILL-node.md +141 -0
  96. package/templates/skills/setup-api-tests/SKILL.md +9 -0
  97. package/templates/skills/setup-mutation-tests/SKILL-java.md +303 -0
  98. package/templates/skills/setup-mutation-tests/SKILL-node.md +408 -0
  99. package/templates/skills/setup-mutation-tests/SKILL.md +9 -0
  100. package/templates/skills/setup-test-pipeline/SKILL-java.md +454 -0
  101. package/templates/skills/setup-test-pipeline/SKILL-node.md +318 -0
  102. package/templates/skills/setup-test-pipeline/SKILL.md +9 -0
  103. package/templates/skills/write-api-tests/SKILL-java.md +115 -0
  104. package/templates/skills/write-api-tests/SKILL-node.md +83 -0
  105. package/templates/skills/write-api-tests/SKILL.md +9 -0
  106. package/templates/stryker.config.js +50 -0
@@ -0,0 +1,527 @@
1
+ """
2
+ Combined quality report generator — Unit Tests + API Tests in top-level tabs.
3
+ """
4
+
5
+ import glob
6
+ import json
7
+ import os
8
+ from report_utils import (
9
+ get_html_template, build_css_tabs, report_detail_links_row,
10
+ resolve_jacoco_href, resolve_pit_href, resolve_sibling_report_href,
11
+ format_mutator_name,
12
+ )
13
+ from report_unit import parse_surefire_reports, parse_jacoco_report, parse_mutation_data
14
+
15
+
16
+ def generate_combined_report(args):
17
+ output_html = args.output_file
18
+
19
+ surefire = parse_surefire_reports(args.surefire_dir) if args.surefire_dir and os.path.isdir(args.surefire_dir) else None
20
+ jacoco = parse_jacoco_report(args.jacoco_xml) if args.jacoco_xml and os.path.isfile(args.jacoco_xml) else None
21
+ mutation = parse_mutation_data(args.mutations_xml) if args.mutations_xml and os.path.isfile(args.mutations_xml) else None
22
+
23
+ api_coverage = None
24
+ if args.api_coverage_file and os.path.isfile(args.api_coverage_file):
25
+ with open(args.api_coverage_file, 'r') as f:
26
+ api_coverage = json.load(f)
27
+
28
+ postman_total = getattr(args, 'postman_assertions_total', 0) or 0
29
+ postman_passed = getattr(args, 'postman_assertions_passed', 0) or 0
30
+ postman_failed = getattr(args, 'postman_assertions_failed', 0) or 0
31
+ postman_requests = getattr(args, 'postman_requests', 0) or 0
32
+ postman_duration = getattr(args, 'postman_duration', '0') or '0'
33
+ postman_col_passed = getattr(args, 'postman_collections_passed', 0) or 0
34
+ postman_col_failed = getattr(args, 'postman_collections_failed', 0) or 0
35
+
36
+ test_pass_rate = 0
37
+ if surefire and surefire['tests'] > 0:
38
+ test_pass_rate = (surefire['passed'] / surefire['tests']) * 100
39
+
40
+ line_coverage = 0
41
+ if jacoco:
42
+ total_lines = jacoco['line_covered'] + jacoco['line_missed']
43
+ if total_lines > 0:
44
+ line_coverage = (jacoco['line_covered'] / total_lines) * 100
45
+
46
+ branch_coverage = 0
47
+ if jacoco:
48
+ total_branches = jacoco['branch_covered'] + jacoco['branch_missed']
49
+ if total_branches > 0:
50
+ branch_coverage = (jacoco['branch_covered'] / total_branches) * 100
51
+
52
+ mutation_score = 0
53
+ if mutation and mutation['total'] > 0:
54
+ mutation_score = (mutation['killed'] / mutation['total']) * 100
55
+
56
+ api_pass_rate = (postman_passed / postman_total * 100) if postman_total > 0 else 0
57
+ api_coverage_pct = api_coverage.get('coverage_percent', 0) if api_coverage else 0
58
+
59
+ all_tests_pass = surefire and surefire['failures'] == 0 and surefire['errors'] == 0
60
+ good_coverage = line_coverage >= 70
61
+ good_mutation = mutation_score >= 70
62
+ api_tests_pass = postman_failed == 0
63
+
64
+ if all_tests_pass and good_coverage and good_mutation and api_tests_pass:
65
+ health = {'class': 'excellent', 'icon': '🟢', 'label': 'Healthy'}
66
+ elif all_tests_pass and api_tests_pass and (good_coverage or good_mutation):
67
+ health = {'class': 'good', 'icon': '🟡', 'label': 'Good'}
68
+ else:
69
+ health = {'class': 'poor', 'icon': '🔴', 'label': 'Needs Attention'}
70
+
71
+ content = '<div class="summary-grid">'
72
+
73
+ if surefire:
74
+ card_class = 'success' if all_tests_pass else 'error'
75
+ content += f'''
76
+ <div class="summary-card {card_class}">
77
+ <div class="value">{surefire['passed']}/{surefire['tests']}</div>
78
+ <div class="label">Unit Tests</div>
79
+ <div class="detail">{surefire['time']:.1f}s | {surefire['skipped']} skipped</div>
80
+ </div>'''
81
+ else:
82
+ content += '''
83
+ <div class="summary-card">
84
+ <div class="value" style="font-size:1.25rem;color:var(--color-muted);">N/A</div>
85
+ <div class="label">Unit Tests</div>
86
+ <div class="detail">No data available</div>
87
+ </div>'''
88
+
89
+ if jacoco:
90
+ card_class = 'success' if line_coverage >= 80 else 'warning' if line_coverage >= 60 else 'error'
91
+ content += f'''
92
+ <div class="summary-card {card_class}">
93
+ <div class="value">{line_coverage:.1f}%</div>
94
+ <div class="label">Line Coverage</div>
95
+ <div class="detail">{jacoco['line_covered']:,} / {jacoco['line_covered'] + jacoco['line_missed']:,} lines</div>
96
+ </div>'''
97
+ else:
98
+ content += '''
99
+ <div class="summary-card">
100
+ <div class="value" style="font-size:1.25rem;color:var(--color-muted);">N/A</div>
101
+ <div class="label">Line Coverage</div>
102
+ <div class="detail">No data available</div>
103
+ </div>'''
104
+
105
+ if mutation:
106
+ card_class = 'success' if mutation_score >= 80 else 'warning' if mutation_score >= 60 else 'error'
107
+ content += f'''
108
+ <div class="summary-card {card_class}">
109
+ <div class="value">{mutation_score:.1f}%</div>
110
+ <div class="label">Mutation Score</div>
111
+ <div class="detail">{mutation['killed']:,} / {mutation['total']:,} killed</div>
112
+ </div>'''
113
+ else:
114
+ content += '''
115
+ <div class="summary-card">
116
+ <div class="value" style="font-size:1.25rem;color:var(--color-muted);">N/A</div>
117
+ <div class="label">Mutation Score</div>
118
+ <div class="detail">No data available</div>
119
+ </div>'''
120
+
121
+ if postman_total > 0:
122
+ card_class = 'success' if postman_failed == 0 else 'error'
123
+ content += f'''
124
+ <div class="summary-card {card_class}">
125
+ <div class="value">{postman_passed}/{postman_total}</div>
126
+ <div class="label">API Assertions</div>
127
+ <div class="detail">{postman_col_passed + postman_col_failed} collections | {postman_requests} requests</div>
128
+ </div>'''
129
+ else:
130
+ content += '''
131
+ <div class="summary-card">
132
+ <div class="value" style="font-size:1.25rem;color:var(--color-muted);">N/A</div>
133
+ <div class="label">API Assertions</div>
134
+ <div class="detail">No data available</div>
135
+ </div>'''
136
+
137
+ if api_coverage:
138
+ card_class = 'success' if api_coverage_pct >= 80 else 'warning' if api_coverage_pct >= 60 else 'error'
139
+ content += f'''
140
+ <div class="summary-card {card_class}">
141
+ <div class="value">{api_coverage_pct:.0f}%</div>
142
+ <div class="label">API Coverage</div>
143
+ <div class="detail">{api_coverage.get('tested', 0)}/{api_coverage.get('total', 0)} endpoints with 2xx</div>
144
+ </div>'''
145
+ else:
146
+ content += '''
147
+ <div class="summary-card">
148
+ <div class="value" style="font-size:1.25rem;color:var(--color-muted);">N/A</div>
149
+ <div class="label">API Coverage</div>
150
+ <div class="detail">No data available</div>
151
+ </div>'''
152
+
153
+ content += '</div>'
154
+
155
+ jacoco_href = resolve_jacoco_href(output_html, args.jacoco_xml) if jacoco else None
156
+ pit_href = resolve_pit_href(output_html, args.mutations_xml) if mutation else None
157
+
158
+ unit_content = report_detail_links_row([
159
+ ('Detailed Coverage Analysis', 'JaCoCo Coverage Report', jacoco_href),
160
+ ('Detailed Mutation Analysis', 'PIT Mutation Report', pit_href),
161
+ ])
162
+
163
+ unit_content += build_css_tabs('unit', [
164
+ ('results', 'Test Results', _build_unit_test_results(surefire)),
165
+ ('coverage', 'Coverage Analysis', _build_unit_coverage(jacoco)),
166
+ ('mutations', 'Mutation Testing', _build_unit_mutations(mutation)),
167
+ ('survived', 'Survived Mutations', _build_unit_survived(mutation)),
168
+ ])
169
+
170
+ api_coverage_html_path = getattr(args, 'api_coverage_html', None)
171
+ postman_consolidated_html_path = getattr(args, 'postman_consolidated_html', None)
172
+ postman_href = resolve_sibling_report_href(output_html, postman_consolidated_html_path)
173
+ api_coverage_href = resolve_sibling_report_href(output_html, api_coverage_html_path)
174
+
175
+ api_content = report_detail_links_row([
176
+ ('Detailed API Test Results', 'Postman Consolidated Report', postman_href),
177
+ ('Detailed API Coverage Analysis', 'API Coverage Matrix Report', api_coverage_href),
178
+ ])
179
+
180
+ postman_collections = _load_postman_collections(args)
181
+ products_present = sorted(set(c['product'] for c in postman_collections))
182
+ real_products = [p for p in products_present if p and p != 'default']
183
+ is_multi_product = len(real_products) > 1
184
+
185
+ api_results_html = _build_api_results_html(
186
+ postman_collections, real_products, is_multi_product,
187
+ postman_total, postman_passed, postman_failed,
188
+ postman_requests, postman_duration, postman_col_passed, postman_col_failed
189
+ )
190
+ api_coverage_detail_html = _build_api_coverage_detail(api_coverage)
191
+ api_gaps_html = _build_api_gaps(api_coverage)
192
+
193
+ api_content += build_css_tabs('api', [
194
+ ('results', 'API Results', api_results_html),
195
+ ('coverage', 'Coverage Summary', api_coverage_detail_html),
196
+ ('gaps', 'Gaps Analysis', api_gaps_html),
197
+ ])
198
+
199
+ content += '<div class="section">'
200
+ content += build_css_tabs('top', [
201
+ ('unit', 'Unit Tests', unit_content),
202
+ ('api', 'API Tests', api_content),
203
+ ])
204
+ content += '</div>'
205
+
206
+ logo = getattr(args, 'logo', 'Quality Reports')
207
+ env = getattr(args, 'env', None)
208
+ timestamp = getattr(args, 'timestamp', None)
209
+ subtitle = f"{env} environment • {timestamp}" if env and timestamp else None
210
+ html = get_html_template(
211
+ title="Quality Report",
212
+ logo=logo,
213
+ content=content,
214
+ health_badge=health,
215
+ subtitle=subtitle,
216
+ footer_text="Unit Tests + Code Coverage + Mutation Testing + API Integration Tests"
217
+ )
218
+
219
+ with open(args.output_file, 'w') as f:
220
+ f.write(html)
221
+
222
+ print(f"Combined Quality Report generated: {args.output_file}")
223
+ if surefire:
224
+ print(f" Unit Tests: {surefire['passed']}/{surefire['tests']} passed ({test_pass_rate:.1f}%)")
225
+ if jacoco:
226
+ print(f" Line Coverage: {line_coverage:.1f}%")
227
+ if mutation:
228
+ print(f" Mutation Score: {mutation_score:.1f}%")
229
+ if postman_total > 0:
230
+ print(f" API Assertions: {postman_passed}/{postman_total} passed ({api_pass_rate:.1f}%)")
231
+ if api_coverage:
232
+ print(f" API Coverage: {api_coverage_pct:.1f}%")
233
+
234
+
235
+ def _load_postman_collections(args):
236
+ def _product_slug(name):
237
+ return name.lower().replace(' ', '-').replace('_', '-')
238
+
239
+ def _parse_collection(jf, product_slug):
240
+ with open(jf, 'r') as f:
241
+ data = json.load(f)
242
+ run = data.get('run', {})
243
+ stats = run.get('stats', {})
244
+ timings = run.get('timings', {})
245
+ col_name = data.get('collection', {}).get('info', {}).get('name', os.path.basename(jf))
246
+
247
+ if not product_slug:
248
+ env_name = ''
249
+ env = data.get('environment')
250
+ if isinstance(env, dict):
251
+ env_name = env.get('name', '')
252
+ if ' - ' in env_name:
253
+ product_slug = _product_slug(env_name.split(' - ', 1)[-1])
254
+ else:
255
+ product_slug = 'default'
256
+
257
+ assertions = stats.get('assertions', {})
258
+ requests = stats.get('requests', {})
259
+ duration_ms = (timings.get('completed', 0) - timings.get('started', 0))
260
+ total_a = assertions.get('total', 0)
261
+ failed_a = assertions.get('failed', 0)
262
+
263
+ basename_no_ext = os.path.splitext(os.path.basename(jf))[0]
264
+ if product_slug and product_slug != 'default':
265
+ html_href = f"{product_slug}/{basename_no_ext}.html"
266
+ else:
267
+ html_href = f"{basename_no_ext}.html"
268
+
269
+ return {
270
+ 'name': col_name,
271
+ 'product': product_slug,
272
+ 'assertions_total': total_a,
273
+ 'assertions_passed': total_a - failed_a,
274
+ 'assertions_failed': failed_a,
275
+ 'requests_total': requests.get('total', 0),
276
+ 'requests_failed': requests.get('failed', 0),
277
+ 'duration': f"{duration_ms / 1000:.1f}s",
278
+ 'status': 'passed' if failed_a == 0 else 'failed',
279
+ 'html_href': html_href,
280
+ }
281
+
282
+ postman_collections = []
283
+ postman_json_dir = getattr(args, 'postman_json_dir', None)
284
+ if not postman_json_dir or not os.path.isdir(postman_json_dir):
285
+ return postman_collections
286
+
287
+ subdirs = sorted([
288
+ d for d in os.listdir(postman_json_dir)
289
+ if os.path.isdir(os.path.join(postman_json_dir, d))
290
+ ])
291
+ if subdirs:
292
+ for subdir in subdirs:
293
+ slug = _product_slug(subdir)
294
+ for jf in sorted(glob.glob(os.path.join(postman_json_dir, subdir, '*.json'))):
295
+ try:
296
+ postman_collections.append(_parse_collection(jf, slug))
297
+ except Exception as e:
298
+ print(f"Warning: failed to parse collection {jf} (slug={slug}): {e}")
299
+ continue
300
+ # Also include root-level JSON files as default/legacy collections
301
+ for jf in sorted(glob.glob(os.path.join(postman_json_dir, '*.json'))):
302
+ try:
303
+ postman_collections.append(_parse_collection(jf, 'default'))
304
+ except Exception as e:
305
+ print(f"Warning: failed to parse collection {jf} (slug=default): {e}")
306
+ continue
307
+ else:
308
+ for jf in sorted(glob.glob(os.path.join(postman_json_dir, '*.json'))):
309
+ try:
310
+ postman_collections.append(_parse_collection(jf, 'default'))
311
+ except Exception as e:
312
+ print(f"Warning: failed to parse collection {jf} (slug=default): {e}")
313
+ continue
314
+
315
+ return postman_collections
316
+
317
+
318
+ def _display_name_from_slug(slug):
319
+ _ACRONYMS = {'crm', 'api', 'sdk'}
320
+ return ' '.join(
321
+ w.upper() if w.lower() in _ACRONYMS else w.capitalize()
322
+ for w in slug.replace('-', ' ').split()
323
+ )
324
+
325
+
326
+ def _build_api_results_html(postman_collections, real_products, is_multi_product,
327
+ postman_total, postman_passed, postman_failed,
328
+ postman_requests, postman_duration, postman_col_passed, postman_col_failed):
329
+ if postman_collections:
330
+ products_to_show = real_products if is_multi_product else sorted(set(c['product'] for c in postman_collections))
331
+ html = ''
332
+ if is_multi_product:
333
+ html += '<div class="summary-grid" style="margin-bottom:1.5rem;">'
334
+ for slug in products_to_show:
335
+ p_cols = [c for c in postman_collections if c['product'] == slug]
336
+ p_total = sum(c['assertions_total'] for c in p_cols)
337
+ p_fail = sum(c['assertions_failed'] for c in p_cols)
338
+ p_pass = p_total - p_fail
339
+ n_fail_cols = sum(1 for c in p_cols if c['status'] == 'failed')
340
+ card_class = 'success' if p_fail == 0 else 'error'
341
+ status_icon = '✓' if p_fail == 0 else '✗'
342
+ fail_detail = f' &bull; <span style="color:var(--color-error);">{n_fail_cols} failed</span>' if n_fail_cols else ''
343
+ html += f'''<div class="summary-card {card_class}">
344
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:0.5rem;">
345
+ <div class="label" style="margin:0;text-align:left;">{_display_name_from_slug(slug)}</div>
346
+ <span style="font-size:1rem;font-weight:700;">{status_icon}</span>
347
+ </div>
348
+ <div class="value">{p_pass}<span style="font-size:1rem;font-weight:400;color:var(--color-muted);">/{p_total}</span></div>
349
+ <div class="detail">{len(p_cols)} collections{fail_detail}</div>
350
+ </div>'''
351
+ html += '</div>'
352
+
353
+ total_a = sum(c['assertions_total'] for c in postman_collections)
354
+ total_p = sum(c['assertions_passed'] for c in postman_collections)
355
+ total_f = sum(c['assertions_failed'] for c in postman_collections)
356
+ total_r = sum(c['requests_total'] for c in postman_collections)
357
+ html += f'''
358
+ <table>
359
+ <thead><tr><th style="text-align:left;">Metric</th><th>Value</th></tr></thead>
360
+ <tbody>
361
+ <tr><td style="text-align:left;">Assertions</td>
362
+ <td><strong style="color:var(--color-success);">{total_p}</strong> / {total_a}
363
+ {"" if total_f == 0 else f" &nbsp;<span style='color:var(--color-error);'>({total_f} failed)</span>"}
364
+ </td></tr>
365
+ <tr><td style="text-align:left;">Requests</td><td><strong>{total_r}</strong></td></tr>
366
+ <tr><td style="text-align:left;">Duration</td><td>{postman_duration}s</td></tr>
367
+ <tr><td style="text-align:left;">Collections</td>
368
+ <td>{len(postman_collections)} total
369
+ {f" &bull; <span style='color:var(--color-error);'>{sum(1 for c in postman_collections if c['status'] == 'failed')} failed</span>" if any(c['status'] == 'failed' for c in postman_collections) else " &bull; <span style='color:var(--color-success);'>all passed</span>"}
370
+ </td></tr>
371
+ </tbody>
372
+ </table>'''
373
+ return html
374
+ elif postman_total > 0:
375
+ return f'''
376
+ <table>
377
+ <thead><tr><th style="text-align:left;">Metric</th><th>Value</th></tr></thead>
378
+ <tbody>
379
+ <tr><td style="text-align:left;">Collections</td><td><strong>{postman_col_passed + postman_col_failed}</strong> ({postman_col_passed} passed, {postman_col_failed} failed)</td></tr>
380
+ <tr><td style="text-align:left;">Assertions</td><td><strong style="color: var(--color-success);">{postman_passed}</strong> / {postman_total}</td></tr>
381
+ <tr><td style="text-align:left;">Requests</td><td><strong>{postman_requests}</strong></td></tr>
382
+ <tr><td style="text-align:left;">Duration</td><td>{postman_duration}s</td></tr>
383
+ </tbody>
384
+ </table>'''
385
+ return '<p style="color: var(--color-muted); text-align: center; padding: 2rem;">No API test results available</p>'
386
+
387
+
388
+ def _build_api_coverage_detail(api_coverage):
389
+ if not api_coverage:
390
+ return '<p style="color: var(--color-muted); text-align: center; padding: 2rem;">No API coverage data available</p>'
391
+
392
+ by_method = api_coverage.get('by_method', {})
393
+ html = '''
394
+ <div class="section-title">Coverage by HTTP Method</div>
395
+ <table>
396
+ <thead><tr><th style="text-align:left;">Method</th><th>Tested</th><th>Total</th><th>Coverage</th><th style="width:200px;">Progress</th></tr></thead>
397
+ <tbody>'''
398
+ for method in sorted(by_method.keys()):
399
+ stats = by_method[method]
400
+ tested = stats.get('tested', 0)
401
+ total = stats.get('total', 0)
402
+ pct = (tested / total * 100) if total > 0 else 0
403
+ bar_class = 'success' if pct >= 80 else 'warning' if pct >= 60 else 'error'
404
+ html += f'''<tr>
405
+ <td style="text-align:left;"><span class="method method-{method}">{method}</span></td>
406
+ <td>{tested}</td>
407
+ <td>{total}</td>
408
+ <td><strong>{pct:.0f}%</strong></td>
409
+ <td><div class="progress-bar small"><div class="progress-fill {bar_class}" style="width:{pct}%;"></div></div></td>
410
+ </tr>'''
411
+ html += '</tbody></table>'
412
+
413
+ html += f'''
414
+ <div class="section-title" style="margin-top: 2rem;">Overall Metrics</div>
415
+ <table>
416
+ <thead><tr><th style="text-align:left;">Metric</th><th>Value</th></tr></thead>
417
+ <tbody>
418
+ <tr><td style="text-align:left;">Success Path (2xx) Coverage</td><td><strong>{api_coverage.get('coverage_percent', 0):.1f}%</strong></td></tr>
419
+ <tr><td style="text-align:left;">Well Tested (3+ tests)</td><td><strong>{api_coverage.get('well_tested_percent', 0):.1f}%</strong></td></tr>
420
+ <tr><td style="text-align:left;">Query Params Coverage</td><td><strong>{api_coverage.get('query_params_percent', 0):.1f}%</strong></td></tr>
421
+ </tbody>
422
+ </table>'''
423
+ return html
424
+
425
+
426
+ def _build_api_gaps(api_coverage):
427
+ if not api_coverage or not api_coverage.get('gaps'):
428
+ return '<p style="color: var(--color-muted); text-align: center; padding: 2rem;">No gaps found. All endpoints have test coverage.</p>'
429
+
430
+ html = '''
431
+ <div class="section-title">Untested Endpoints</div>
432
+ <table>
433
+ <thead><tr><th style="width:70px;">Method</th><th style="text-align:left;">Endpoint</th><th style="text-align:left;">Controller</th></tr></thead>
434
+ <tbody>'''
435
+ for gap in api_coverage['gaps']:
436
+ html += f'''<tr>
437
+ <td><span class="method method-{gap['method']}">{gap['method']}</span></td>
438
+ <td style="text-align:left;"><span class="path">{gap['path']}</span></td>
439
+ <td style="text-align:left;">{gap.get('controller', '')}</td>
440
+ </tr>'''
441
+ return html + '</tbody></table>'
442
+
443
+
444
+ def _build_unit_test_results(surefire):
445
+ if surefire and surefire['test_classes']:
446
+ html = '''
447
+ <div class="section-title">Test Results by Class</div>
448
+ <table>
449
+ <thead><tr><th style="text-align:left;">Test Class</th><th>Tests</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Time</th></tr></thead>
450
+ <tbody>'''
451
+ for tc in surefire['test_classes'][:20]:
452
+ row_class = 'style="background:#fef2f2;"' if tc['failures'] > 0 or tc['errors'] > 0 else ''
453
+ html += f'''<tr {row_class}>
454
+ <td style="text-align:left;">{tc['name']}</td>
455
+ <td>{tc['tests']}</td>
456
+ <td style="color:#22c55e;">{tc['passed']}</td>
457
+ <td style="color:#ef4444;">{tc['failures'] + tc['errors']}</td>
458
+ <td style="color:#f59e0b;">{tc['skipped']}</td>
459
+ <td>{tc['time']:.2f}s</td>
460
+ </tr>'''
461
+ return html + '</tbody></table>'
462
+ return '<p style="color: var(--color-muted); text-align: center; padding: 2rem;">No test results available</p>'
463
+
464
+
465
+ def _build_unit_coverage(jacoco):
466
+ if jacoco and jacoco['packages']:
467
+ html = '''
468
+ <div class="section-title">Coverage by Package (Lowest First)</div>
469
+ <table>
470
+ <thead><tr><th style="text-align:left;">Package</th><th>Coverage</th><th style="width:200px;">Progress</th><th>Covered</th><th>Missed</th></tr></thead>
471
+ <tbody>'''
472
+ for pkg in jacoco['packages'][:15]:
473
+ cov = pkg.get('coverage', 0)
474
+ bar_class = 'success' if cov >= 80 else 'warning' if cov >= 60 else 'error'
475
+ short_name = pkg['name'].split('.')[-2] + '.' + pkg['name'].split('.')[-1] if '.' in pkg['name'] else pkg['name']
476
+ html += f'''<tr>
477
+ <td style="text-align:left;" title="{pkg['name']}">{short_name}</td>
478
+ <td><strong>{cov:.1f}%</strong></td>
479
+ <td><div class="progress-bar small"><div class="progress-fill {bar_class}" style="width:{cov}%;"></div></div></td>
480
+ <td>{pkg['line_covered']}</td>
481
+ <td>{pkg['line_missed']}</td>
482
+ </tr>'''
483
+ return html + '</tbody></table>'
484
+ return '<p style="color: var(--color-muted); text-align: center; padding: 2rem;">No coverage data available</p>'
485
+
486
+
487
+ def _build_unit_mutations(mutation):
488
+ if mutation and mutation['mutator_stats']:
489
+ html = '''
490
+ <div class="section-title">Mutation Analysis by Mutator</div>
491
+ <table>
492
+ <thead><tr><th style="text-align:left;">Mutator</th><th>Kill Rate</th><th style="width:150px;">Progress</th><th>Killed</th><th>Survived</th><th>Total</th></tr></thead>
493
+ <tbody>'''
494
+ sorted_mutators = sorted(mutation['mutator_stats'].items(),
495
+ key=lambda x: x[1]['killed']/x[1]['total'] if x[1]['total'] > 0 else 0,
496
+ reverse=True)
497
+ for mutator, stats in sorted_mutators:
498
+ rate = (stats['killed'] / stats['total'] * 100) if stats['total'] > 0 else 0
499
+ bar_class = 'success' if rate >= 80 else 'warning' if rate >= 60 else 'error'
500
+ readable_name = format_mutator_name(mutator)
501
+ html += f'''<tr>
502
+ <td style="text-align:left;" title="{mutator}">{readable_name}</td>
503
+ <td><strong>{rate:.1f}%</strong></td>
504
+ <td><div class="progress-bar small"><div class="progress-fill {bar_class}" style="width:{rate}%;"></div></div></td>
505
+ <td>{stats['killed']}</td><td>{stats['survived']}</td><td>{stats['total']}</td>
506
+ </tr>'''
507
+ return html + '</tbody></table>'
508
+ return '<p style="color: var(--color-muted); text-align: center; padding: 2rem;">No mutation data available</p>'
509
+
510
+
511
+ def _build_unit_survived(mutation):
512
+ if mutation and mutation['survived_mutations']:
513
+ html = '''
514
+ <div class="section-title">Survived Mutations (Test Gaps)</div>
515
+ <table>
516
+ <thead><tr><th style="text-align:left;">Class</th><th style="text-align:left;">Method</th><th>Line</th><th style="text-align:left;">Description</th></tr></thead>
517
+ <tbody>'''
518
+ for sm in mutation['survived_mutations']:
519
+ desc = sm['description'][:80] + '...' if len(sm['description']) > 80 else sm['description']
520
+ html += f'''<tr>
521
+ <td style="text-align:left;">{sm['class']}</td>
522
+ <td style="text-align:left;"><code>{sm['method']}</code></td>
523
+ <td>{sm['line']}</td>
524
+ <td style="text-align:left;font-size:0.8em;">{desc}</td>
525
+ </tr>'''
526
+ return html + '</tbody></table>'
527
+ return '<p style="color: var(--color-muted); text-align: center; padding: 2rem;">No survived mutations. All mutations were killed by tests.</p>'