@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,363 @@
1
+ """
2
+ Consolidated Newman test results report generator (API Test Quality Report).
3
+ """
4
+
5
+ import glob
6
+ import json
7
+ import os
8
+ from html import escape as esc
9
+ from report_utils import get_html_template
10
+
11
+
12
+ _HTML_EXTRA_HASH_NAV_MARKER = 'mp-installation:htmlextra-hash-nav'
13
+ _HTML_EXTRA_HASH_NAV_SCRIPT = '''<script>
14
+ /* mp-installation:htmlextra-hash-nav */
15
+ (function() {
16
+ function showFailedTab() {
17
+ if (window.location.hash !== '#pills-failed' || !window.jQuery) {
18
+ return;
19
+ }
20
+ var $tab = jQuery('.nav-tabs a[href="#pills-failed"]');
21
+ if ($tab.length) {
22
+ $tab.tab('show');
23
+ }
24
+ var el = document.getElementById('pills-failed');
25
+ if (el) {
26
+ el.scrollIntoView({block: 'start'});
27
+ }
28
+ }
29
+ if (document.readyState === 'loading') {
30
+ document.addEventListener('DOMContentLoaded', showFailedTab);
31
+ } else {
32
+ showFailedTab();
33
+ }
34
+ window.addEventListener('hashchange', showFailedTab);
35
+ })();
36
+ </script>'''
37
+
38
+
39
+ def patch_htmlextra_reports_for_hash_navigation(reports_dir):
40
+ """Inject script so consolidated links to #pills-failed open the Failed Tests tab."""
41
+ if not reports_dir or not os.path.isdir(reports_dir):
42
+ return 0
43
+ patched = 0
44
+ for root, _dirs, files in os.walk(reports_dir):
45
+ for name in files:
46
+ if not name.endswith('.html'):
47
+ continue
48
+ if name.startswith('consolidated-') or name.startswith('quality-'):
49
+ continue
50
+ path = os.path.join(root, name)
51
+ try:
52
+ with open(path, 'r', encoding='utf-8') as f:
53
+ html = f.read()
54
+ except OSError:
55
+ continue
56
+ if 'id="pills-failed"' not in html:
57
+ continue
58
+ if _HTML_EXTRA_HASH_NAV_MARKER in html:
59
+ continue
60
+ if '</body>' not in html:
61
+ continue
62
+ updated = html.replace('</body>', _HTML_EXTRA_HASH_NAV_SCRIPT + '\n</body>', 1)
63
+ with open(path, 'w', encoding='utf-8') as f:
64
+ f.write(updated)
65
+ patched += 1
66
+ if patched:
67
+ print(f"Patched {patched} htmlextra report(s) for #pills-failed deep links")
68
+ return patched
69
+
70
+
71
+ def generate_consolidated_report(args):
72
+
73
+ def _product_slug(name):
74
+ return name.lower().replace(' ', '-').replace('_', '-')
75
+
76
+ def _parse_col_entry(jf, product_slug):
77
+ with open(jf, 'r') as f:
78
+ data = json.load(f)
79
+ col_name = data.get('collection', {}).get('info', {}).get('name', os.path.basename(jf))
80
+ if not product_slug:
81
+ env = data.get('environment') or {}
82
+ env_name = env.get('name', '') if isinstance(env, dict) else ''
83
+ product_slug = _product_slug(env_name.split(' - ', 1)[-1]) if ' - ' in env_name else 'legacy'
84
+ basename_no_ext = os.path.splitext(os.path.basename(jf))[0]
85
+ html_href = f"{product_slug}/{basename_no_ext}.html" if product_slug != 'legacy' else f"{basename_no_ext}.html"
86
+
87
+ run = data.get('run', {})
88
+ stats = run.get('stats', {})
89
+ assertions = stats.get('assertions', {})
90
+ total_a = int(assertions.get('total', 0) or 0)
91
+ failed_a = int(assertions.get('failed', 0) or 0)
92
+ passed_a = max(total_a - failed_a, 0)
93
+
94
+ failure_labels = []
95
+ for failure in (run.get('failures') or [])[:5]:
96
+ err = failure.get('error') if isinstance(failure, dict) else None
97
+ if isinstance(err, dict):
98
+ label = err.get('test') or err.get('message') or ''
99
+ if label:
100
+ failure_labels.append(str(label))
101
+
102
+ detail_href = f"{html_href}#pills-failed" if failed_a > 0 else html_href
103
+
104
+ return {
105
+ 'name': col_name,
106
+ 'file': html_href,
107
+ 'detail_href': detail_href,
108
+ 'product_slug': product_slug,
109
+ 'assertions_total': total_a,
110
+ 'assertions_passed': passed_a,
111
+ 'assertions_failed': failed_a,
112
+ 'failure_labels': failure_labels,
113
+ 'status': 'passed' if failed_a == 0 else 'failed',
114
+ }
115
+
116
+ collections = []
117
+ postman_json_dir = getattr(args, 'postman_json_dir', None)
118
+ if postman_json_dir and os.path.isdir(postman_json_dir):
119
+ subdirs = sorted([d for d in os.listdir(postman_json_dir) if os.path.isdir(os.path.join(postman_json_dir, d))])
120
+ if subdirs:
121
+ for subdir in subdirs:
122
+ slug = _product_slug(subdir)
123
+ for jf in sorted(glob.glob(os.path.join(postman_json_dir, subdir, '*.json'))):
124
+ try:
125
+ collections.append(_parse_col_entry(jf, slug))
126
+ except Exception as e:
127
+ continue
128
+ for jf in sorted(glob.glob(os.path.join(postman_json_dir, '*.json'))):
129
+ try:
130
+ collections.append(_parse_col_entry(jf, 'legacy'))
131
+ except Exception as e:
132
+ continue
133
+ elif args.collection_links:
134
+ try:
135
+ for col in json.loads(args.collection_links):
136
+ f = col.get('file', '')
137
+ slug = f.split('/')[0] if '/' in f else 'legacy'
138
+ collections.append({'name': col.get('name', ''), 'file': f, 'product_slug': slug})
139
+ except json.JSONDecodeError:
140
+ pass
141
+
142
+ api_coverage = None
143
+ if hasattr(args, 'api_coverage_data') and args.api_coverage_data:
144
+ try:
145
+ api_coverage = json.loads(args.api_coverage_data)
146
+ except json.JSONDecodeError:
147
+ pass
148
+
149
+ status_class = "excellent" if args.status == "PASSED" else "poor"
150
+ status_icon = "✓" if args.status == "PASSED" else "✗"
151
+ status_text = "All Passed" if args.status == "PASSED" else "Failures Detected"
152
+ health = {'class': status_class, 'icon': status_icon, 'label': status_text}
153
+
154
+ content = '<div class="summary-grid">'
155
+ content += f'<div class="summary-card"><div class="value">{args.total_assertions}</div><div class="label">Total Tests</div></div>'
156
+ content += f'<div class="summary-card success"><div class="value">{args.passed_assertions}</div><div class="label">Passed</div></div>'
157
+
158
+ failed_class = 'error' if args.failed_assertions > 0 else 'success'
159
+ content += f'<div class="summary-card {failed_class}"><div class="value">{args.failed_assertions}</div><div class="label">Failed</div></div>'
160
+ content += f'<div class="summary-card"><div class="value">{args.total_requests}</div><div class="label">Requests</div></div>'
161
+
162
+ if api_coverage:
163
+ cov_pct = api_coverage.get('coverage_percent', 0)
164
+ cov_class = 'success' if cov_pct >= 80 else 'warning' if cov_pct >= 60 else 'error'
165
+ tested = api_coverage.get('tested', 0)
166
+ total = api_coverage.get('total', 0)
167
+ content += f'<div class="summary-card {cov_class}"><div class="value">{cov_pct:.0f}%</div><div class="label">API Coverage</div><div class="detail">{tested}/{total} endpoints</div></div>'
168
+
169
+ content += '</div>'
170
+
171
+ if collections:
172
+ collections = sorted(
173
+ collections,
174
+ key=lambda c: (0 if c.get('assertions_failed', 0) > 0 else 1, c.get('name', '')),
175
+ )
176
+
177
+ def _col_product(col):
178
+ return col.get('product_slug', 'legacy')
179
+
180
+ def _slug_to_display(slug):
181
+ _KNOWN = {'crm': 'CRM', 'api': 'API', 'sdk': 'SDK'}
182
+ if slug == 'legacy':
183
+ return 'Legacy'
184
+ return ' '.join(_KNOWN.get(w.lower(), w.capitalize()) for w in slug.replace('-', ' ').split())
185
+
186
+ products = sorted(set(_col_product(c) for c in collections))
187
+ real_products = [p for p in products if p != 'legacy']
188
+ is_multi = len(real_products) > 1
189
+ has_any_failures = any(c.get('assertions_failed', 0) > 0 for c in collections)
190
+ failure_collection_count = sum(1 for c in collections if c.get('assertions_failed', 0) > 0)
191
+
192
+ def _render_collection_row(col):
193
+ p = _col_product(col)
194
+ css_safe = p.replace('-', '_')
195
+ row_classes = ['cr-row']
196
+ if is_multi:
197
+ row_classes.append(f'cr-prod-row cr-prod-{css_safe}')
198
+ failed_a = col.get('assertions_failed', 0) or 0
199
+ total_a = col.get('assertions_total', 0) or 0
200
+ passed_a = col.get('assertions_passed', 0) or 0
201
+ if failed_a > 0:
202
+ row_classes.append('cr-has-failures')
203
+ href = col.get('detail_href') or col.get('file', '#')
204
+ link_text = 'View failures →' if failed_a > 0 else 'View Details →'
205
+
206
+ status_parts = []
207
+ if total_a > 0:
208
+ if failed_a > 0:
209
+ tip = esc(', '.join(col.get('failure_labels', [])[:3]))
210
+ status_parts.append(
211
+ f'<span class="cr-badge cr-badge-fail" title="{tip}">{failed_a} failed</span>'
212
+ )
213
+ status_parts.append(
214
+ f'<span class="cr-badge cr-badge-muted">{passed_a}/{total_a} passed</span>'
215
+ )
216
+ else:
217
+ status_parts.append(
218
+ f'<span class="cr-badge cr-badge-pass">{total_a} passed</span>'
219
+ )
220
+ status_html = ''.join(status_parts)
221
+
222
+ return f'''<div class="card {" ".join(row_classes)}">
223
+ <span class="cr-row-name">{esc(col.get('name', 'Unknown'))}</span>
224
+ <div class="cr-row-actions">
225
+ {status_html}
226
+ <a href="{href}" class="cr-row-link">{link_text}</a>
227
+ </div>
228
+ </div>'''
229
+
230
+ all_filter_groups = (real_products + (['legacy'] if 'legacy' in products else [])) if is_multi else []
231
+
232
+ style_rules = [
233
+ '.cr-row { display:flex; justify-content:space-between; align-items:center; gap:1rem; '
234
+ 'padding:1rem 1.25rem; margin-bottom:0.625rem; }',
235
+ '.cr-row-name { font-weight:500; flex:1; min-width:0; }',
236
+ '.cr-row-actions { display:flex; align-items:center; gap:0.625rem; flex-shrink:0; flex-wrap:wrap; '
237
+ 'justify-content:flex-end; }',
238
+ '.cr-row-link { color:var(--color-primary); text-decoration:none; font-weight:500; white-space:nowrap; }',
239
+ '.cr-badge { font-size:0.75rem; padding:0.25rem 0.65rem; border-radius:999px; font-weight:600; '
240
+ 'white-space:nowrap; }',
241
+ '.cr-badge-pass { background:var(--color-success-bg); color:#047857; }',
242
+ '.cr-badge-fail { background:var(--color-error-bg); color:#b91c1c; }',
243
+ '.cr-badge-muted { background:#f1f5f9; color:var(--color-muted); font-weight:500; }',
244
+ '.cr-row.cr-has-failures { border:1px solid #fecaca; background:#fffbfb; }',
245
+ '.pf-bar { display:flex; gap:0.5rem; justify-content:flex-end; margin-bottom:0.75rem; flex-wrap:wrap; }',
246
+ '.pf-label { padding:0.35rem 0.85rem; border-radius:20px; cursor:pointer; font-size:0.85rem; '
247
+ 'font-weight:500; border:1px solid var(--color-border); color:var(--color-muted); '
248
+ 'background:var(--color-card); transition:all 0.15s; }',
249
+ ]
250
+ parts = ['<div class="cr-filter-wrap">']
251
+
252
+ if has_any_failures:
253
+ parts.append('<input type="radio" name="cr-view" id="cr-view-all" checked style="display:none;">')
254
+ parts.append('<input type="radio" name="cr-view" id="cr-view-failed" style="display:none;">')
255
+ style_rules.append(
256
+ '#cr-view-failed:checked ~ .cr-rows .cr-row:not(.cr-has-failures) { display: none; }'
257
+ )
258
+ style_rules.append(
259
+ '#cr-view-failed:checked ~ .pf-bar-view label[for="cr-view-failed"] '
260
+ '{ background:var(--color-error); color:#fff; border-color:var(--color-error); }'
261
+ )
262
+ style_rules.append(
263
+ '#cr-view-all:checked ~ .pf-bar-view label[for="cr-view-all"] '
264
+ '{ background:var(--color-primary); color:#fff; border-color:var(--color-primary); }'
265
+ )
266
+ parts.append(
267
+ f'<div class="pf-bar pf-bar-view" style="justify-content:flex-start;margin-bottom:0.5rem;">'
268
+ f'<label for="cr-view-all" class="pf-label">All collections</label>'
269
+ f'<label for="cr-view-failed" class="pf-label">Failures only '
270
+ f'({failure_collection_count})</label></div>'
271
+ )
272
+
273
+ if is_multi:
274
+ parts.append('<input type="radio" name="cr-pf" id="cr-pf-all" checked style="display:none;">')
275
+ for p in all_filter_groups:
276
+ css_safe = p.replace('-', '_')
277
+ parts.append(f'<input type="radio" name="cr-pf" id="cr-pf-{css_safe}" style="display:none;">')
278
+ style_rules.append(f'#cr-pf-{css_safe}:checked ~ .cr-rows .cr-prod-row:not(.cr-prod-{css_safe}) {{ display: none; }}')
279
+ style_rules.append(f'#cr-pf-{css_safe}:checked ~ .pf-bar-product label[for="cr-pf-{css_safe}"] {{ background:var(--color-primary); color:#fff; border-color:var(--color-primary); }}')
280
+ style_rules.append('#cr-pf-all:checked ~ .pf-bar-product label[for="cr-pf-all"] { background:var(--color-primary); color:#fff; border-color:var(--color-primary); }')
281
+
282
+ label_html = '<label for="cr-pf-all" class="pf-label">All</label>'
283
+ for p in all_filter_groups:
284
+ css_safe = p.replace('-', '_')
285
+ label_html += f'<label for="cr-pf-{css_safe}" class="pf-label">{_slug_to_display(p)}</label>'
286
+ parts.append(f'<div class="pf-bar pf-bar-product">{label_html}</div>')
287
+
288
+ parts.append('<div class="cr-rows">')
289
+ for col in collections:
290
+ parts.append(_render_collection_row(col))
291
+ parts.append('</div>') # close cr-rows
292
+ parts.append('</div>') # close cr-filter-wrap
293
+
294
+ style_block = '<style>\n' + '\n'.join(style_rules) + '\n</style>\n' if style_rules else ''
295
+ col_html = f'<div class="section-title">Collection Reports <span class="count">{len(collections)} collections</span></div>'
296
+ col_html += style_block + ''.join(parts)
297
+ content += f'<div class="section">{col_html}</div>'
298
+
299
+ if api_coverage:
300
+ gaps = api_coverage.get('gaps', [])
301
+ methods = api_coverage.get('by_method', {})
302
+
303
+ if methods:
304
+ content += '<div class="section"><div class="section-title">Coverage by HTTP Method</div>'
305
+ content += '<table><thead><tr><th>Method</th><th>Coverage</th><th style="width:200px;">Progress</th><th>Tested</th><th>Total</th></tr></thead><tbody>'
306
+ for method in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']:
307
+ if method in methods:
308
+ m = methods[method]
309
+ pct = (m['tested'] / m['total'] * 100) if m['total'] > 0 else 0
310
+ bar_class = 'success' if pct >= 80 else 'warning' if pct >= 60 else 'error'
311
+ content += f'''<tr>
312
+ <td><span class="method-{method.lower()}">{method}</span></td>
313
+ <td><strong>{pct:.0f}%</strong></td>
314
+ <td><div class="progress-bar small"><div class="progress-fill {bar_class}" style="width:{pct}%;"></div></div></td>
315
+ <td>{m['tested']}</td><td>{m['total']}</td>
316
+ </tr>'''
317
+ content += '</tbody></table></div>'
318
+
319
+ if gaps:
320
+ content += f'<div class="section"><div class="section-title">Untested Endpoints <span class="count">{len(gaps)} gaps</span></div>'
321
+ content += '<table><thead><tr><th>Method</th><th style="text-align:left;">Endpoint</th><th style="text-align:left;">Controller</th></tr></thead><tbody>'
322
+ for gap in gaps[:15]:
323
+ content += f'''<tr>
324
+ <td><span class="method-{gap.get('method', 'get').lower()}">{gap.get('method', 'GET')}</span></td>
325
+ <td style="text-align:left;"><code>{gap.get('path', '')}</code></td>
326
+ <td style="text-align:left;">{gap.get('controller', '')}</td>
327
+ </tr>'''
328
+ if len(gaps) > 15:
329
+ content += f'<tr><td colspan="3" style="text-align:center;color:var(--color-muted);">... and {len(gaps) - 15} more untested endpoints</td></tr>'
330
+ content += '</tbody></table></div>'
331
+
332
+ cov_report = api_coverage.get('full_report', '')
333
+ if cov_report:
334
+ content += f'''<div style="text-align: center; margin-top: 1rem;">
335
+ <a href="{cov_report}" style="color: var(--color-primary); text-decoration: none; font-weight: 500;">
336
+ View Full API Coverage Report →
337
+ </a>
338
+ </div>'''
339
+
340
+ content += f'''
341
+ <div style="text-align: center; margin-top: 2rem; padding: 1rem; background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius-sm);">
342
+ <span style="color: var(--color-muted);">Total Duration: <strong style="color: var(--color-heading);">{args.duration}s</strong></span>
343
+ </div>
344
+ '''
345
+
346
+ logo = getattr(args, 'logo', 'Quality Reports')
347
+ html = get_html_template(
348
+ title="API Test Quality Report",
349
+ logo=logo,
350
+ content=content,
351
+ health_badge=health,
352
+ subtitle=f"{args.env} environment • {args.timestamp}",
353
+ footer_text="Test Results + API Coverage"
354
+ )
355
+
356
+ with open(args.output_file, 'w') as f:
357
+ f.write(html)
358
+
359
+ print(f"Report generated: {args.output_file}")
360
+ if api_coverage:
361
+ print(f" API Coverage: {api_coverage.get('coverage_percent', 0):.0f}% ({api_coverage.get('tested', 0)}/{api_coverage.get('total', 0)} endpoints)")
362
+
363
+ patch_htmlextra_reports_for_hash_navigation(os.path.dirname(args.output_file))
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Universal HTML Report Generator
4
+ ================================
5
+ Entry point — dispatches to the appropriate report module.
6
+
7
+ Usage:
8
+ python3 report_generator.py migration <mapping_file> <output_file> [options]
9
+ python3 report_generator.py consolidated <output_file> [options]
10
+ python3 report_generator.py mutation <mutations_xml> <output_file>
11
+ python3 report_generator.py unit-test <output_file> [options]
12
+ python3 report_generator.py combined <output_file> [options]
13
+ python3 report_generator.py css
14
+ """
15
+
16
+ import sys
17
+ import os
18
+ import argparse
19
+ from datetime import datetime
20
+
21
+ # Ensure lib/ is on the path when invoked directly
22
+ sys.path.insert(0, os.path.dirname(__file__))
23
+
24
+ from report_utils import SHARED_CSS
25
+ from report_migration import generate_migration_report
26
+ from report_consolidated import generate_consolidated_report
27
+ from report_mutation import generate_mutation_report
28
+ from report_unit import generate_unit_test_report
29
+ from report_combined import generate_combined_report
30
+
31
+ DEFAULT_LOGO = 'Quality Reports'
32
+
33
+
34
+ def main():
35
+ parser = argparse.ArgumentParser(description='Universal Report Generator')
36
+ subparsers = parser.add_subparsers(dest='command', help='Report type')
37
+
38
+ subparsers.add_parser('css', help='Print shared CSS')
39
+
40
+ mig = subparsers.add_parser('migration', help='Generate migration report')
41
+ mig.add_argument('mapping_file')
42
+ mig.add_argument('output_file')
43
+ mig.add_argument('--logo', default=DEFAULT_LOGO)
44
+ mig.add_argument('--source-folder', required=True)
45
+ mig.add_argument('--postman-folder', required=True)
46
+ mig.add_argument('--source-files', type=int, default=0)
47
+ mig.add_argument('--source-tests', type=int, default=0)
48
+ mig.add_argument('--source-suites', type=int, default=0)
49
+ mig.add_argument('--postman-files', type=int, default=0)
50
+ mig.add_argument('--postman-requests', type=int, default=0)
51
+ mig.add_argument('--postman-assertions', type=int, default=0)
52
+ mig.add_argument('--postman-only-file')
53
+ mig.add_argument('--postman-scenarios')
54
+
55
+ con = subparsers.add_parser('consolidated', help='Generate API test quality report')
56
+ con.add_argument('output_file')
57
+ con.add_argument('--logo', default=DEFAULT_LOGO)
58
+ con.add_argument('--env', default='local')
59
+ con.add_argument('--timestamp', default=datetime.now().strftime('%Y%m%d-%H%M%S'))
60
+ con.add_argument('--total-assertions', type=int, default=0)
61
+ con.add_argument('--passed-assertions', type=int, default=0)
62
+ con.add_argument('--failed-assertions', type=int, default=0)
63
+ con.add_argument('--total-requests', type=int, default=0)
64
+ con.add_argument('--duration', default='0')
65
+ con.add_argument('--status', default='PASSED', choices=['PASSED', 'FAILED'])
66
+ con.add_argument('--collection-links')
67
+ con.add_argument('--postman-json-dir')
68
+ con.add_argument('--api-coverage-data')
69
+
70
+ mut = subparsers.add_parser('mutation', help='Generate mutation report')
71
+ mut.add_argument('mutations_xml')
72
+ mut.add_argument('output_file')
73
+ mut.add_argument('--logo', default=DEFAULT_LOGO)
74
+
75
+ unit = subparsers.add_parser('unit-test', help='Generate unit test quality report')
76
+ unit.add_argument('output_file')
77
+ unit.add_argument('--logo', default=DEFAULT_LOGO)
78
+ unit.add_argument('--surefire-dir')
79
+ unit.add_argument('--jacoco-xml')
80
+ unit.add_argument('--mutations-xml')
81
+
82
+ comb = subparsers.add_parser('combined', help='Generate combined quality report')
83
+ comb.add_argument('output_file')
84
+ comb.add_argument('--logo', default=DEFAULT_LOGO)
85
+ comb.add_argument('--surefire-dir')
86
+ comb.add_argument('--jacoco-xml')
87
+ comb.add_argument('--mutations-xml')
88
+ comb.add_argument('--postman-assertions-total', type=int, default=0)
89
+ comb.add_argument('--postman-assertions-passed', type=int, default=0)
90
+ comb.add_argument('--postman-assertions-failed', type=int, default=0)
91
+ comb.add_argument('--postman-requests', type=int, default=0)
92
+ comb.add_argument('--postman-duration', default='0')
93
+ comb.add_argument('--postman-collections-passed', type=int, default=0)
94
+ comb.add_argument('--postman-collections-failed', type=int, default=0)
95
+ comb.add_argument('--api-coverage-file')
96
+ comb.add_argument('--api-coverage-html')
97
+ comb.add_argument('--postman-json-dir')
98
+ comb.add_argument('--postman-consolidated-html')
99
+ comb.add_argument('--env', default=None)
100
+ comb.add_argument('--timestamp', default=None)
101
+
102
+ args = parser.parse_args()
103
+
104
+ if args.command == 'css':
105
+ print(SHARED_CSS)
106
+ elif args.command == 'migration':
107
+ generate_migration_report(args)
108
+ elif args.command == 'consolidated':
109
+ generate_consolidated_report(args)
110
+ elif args.command == 'mutation':
111
+ generate_mutation_report(args)
112
+ elif args.command == 'unit-test':
113
+ generate_unit_test_report(args)
114
+ elif args.command == 'combined':
115
+ generate_combined_report(args)
116
+ else:
117
+ parser.print_help()
118
+
119
+
120
+ if __name__ == '__main__':
121
+ main()
@@ -0,0 +1,156 @@
1
+ """
2
+ Migration report generator — compares source test files to Postman collections.
3
+ """
4
+
5
+ import os
6
+ from html import escape as esc
7
+ from report_utils import get_html_template
8
+
9
+
10
+ def generate_migration_report(args):
11
+ mappings = []
12
+ with open(args.mapping_file, 'r') as f:
13
+ for line in f:
14
+ line = line.strip()
15
+ if not line:
16
+ continue
17
+ parts = line.split('|')
18
+ if len(parts) >= 5:
19
+ mappings.append({
20
+ 'filename': parts[0],
21
+ 'folder': parts[1],
22
+ 'test_count': parts[2],
23
+ 'status': parts[3],
24
+ 'collection': parts[4] if len(parts) > 4 else ''
25
+ })
26
+
27
+ postman_only = []
28
+ if args.postman_only_file and os.path.exists(args.postman_only_file):
29
+ with open(args.postman_only_file, 'r') as f:
30
+ for line in f:
31
+ line = line.strip()
32
+ if not line:
33
+ continue
34
+ parts = line.split('|')
35
+ if len(parts) >= 5:
36
+ postman_only.append({
37
+ 'filename': parts[0],
38
+ 'folder': parts[1],
39
+ 'name': parts[2],
40
+ 'requests': parts[3],
41
+ 'assertions': parts[4]
42
+ })
43
+
44
+ total_source = len(mappings)
45
+ migrated = sum(1 for m in mappings if m['status'] == 'MIGRATED')
46
+ partial = sum(1 for m in mappings if m['status'] == 'PARTIAL')
47
+ not_found = sum(1 for m in mappings if m['status'] == 'NOT_FOUND')
48
+
49
+ if not_found == 0 and partial == 0 and migrated > 0:
50
+ health = {'class': 'excellent', 'icon': '🟢', 'label': 'Complete'}
51
+ migration_percent = 100
52
+ health_desc = "All source tests have been fully migrated"
53
+ elif not_found == 0 and partial > 0:
54
+ health = {'class': 'good', 'icon': '🟡', 'label': 'Mostly Complete'}
55
+ migration_percent = (migrated * 100 + partial * 50) // total_source if total_source > 0 else 100
56
+ health_desc = f"{partial} file(s) have partial matches"
57
+ elif total_source > 0 and migrated * 100 // total_source >= 70:
58
+ health = {'class': 'good', 'icon': '🟡', 'label': 'In Progress'}
59
+ migration_percent = migrated * 100 // total_source
60
+ health_desc = f"{not_found} file(s) still need migration"
61
+ else:
62
+ health = {'class': 'poor', 'icon': '🔴', 'label': 'In Progress'}
63
+ migration_percent = migrated * 100 // total_source if total_source > 0 else 0
64
+ health_desc = f"Significant gaps - {not_found} file(s) not migrated"
65
+
66
+ progress_class = "success" if migration_percent >= 90 else "warning" if migration_percent >= 70 else "error"
67
+ progress_color = "var(--color-success)" if migration_percent >= 90 else "var(--color-warning)" if migration_percent >= 70 else "var(--color-error)"
68
+
69
+ content = f'''
70
+ <div class="progress-container">
71
+ <div class="progress-header">
72
+ <span class="progress-title">Migration Progress</span>
73
+ <span class="progress-value" style="color: {progress_color};">{migration_percent}%</span>
74
+ </div>
75
+ <div class="progress-bar">
76
+ <div class="progress-fill {progress_class}" style="width: {migration_percent}%;"></div>
77
+ </div>
78
+ <p style="margin-top: 0.75rem; color: var(--color-muted); font-size: 0.85rem;">{health_desc}</p>
79
+ </div>
80
+
81
+ <div class="summary-grid">
82
+ <div class="summary-card health {health['class']}">
83
+ <div class="icon">{health['icon']}</div>
84
+ <div class="value" style="font-size: 1.5rem;">{health['label']}</div>
85
+ <div class="label">Status</div>
86
+ </div>
87
+ <div class="summary-card success">
88
+ <div class="value">{migrated}</div>
89
+ <div class="label">Migrated</div>
90
+ </div>
91
+ <div class="summary-card {'warning' if partial > 0 else ''}">
92
+ <div class="value">{partial}</div>
93
+ <div class="label">Partial</div>
94
+ </div>
95
+ <div class="summary-card {'error' if not_found > 0 else 'success'}">
96
+ <div class="value">{not_found}</div>
97
+ <div class="label">Not Migrated</div>
98
+ </div>
99
+ </div>
100
+
101
+ <div class="section">
102
+ <div class="section-title">Source vs Target Comparison</div>
103
+ <div class="grid-3">
104
+ <div class="card">
105
+ <h4 style="color: var(--color-muted); font-size: 0.85rem; text-transform: uppercase; margin-bottom: 0.875rem;">Source (JavaScript/Mocha)</h4>
106
+ <div style="font-size: 2.5rem; font-weight: 700; color: var(--color-muted);">{args.source_files}</div>
107
+ <div style="color: var(--color-muted); font-size: 0.85rem;">Files</div>
108
+ <div style="margin-top: 0.5rem; font-size: 0.9rem;"><strong>{args.source_tests}</strong> tests • <strong>{args.source_suites}</strong> suites</div>
109
+ </div>
110
+ <div class="card">
111
+ <h4 style="color: var(--color-muted); font-size: 0.85rem; text-transform: uppercase; margin-bottom: 0.875rem;">Target (Postman)</h4>
112
+ <div style="font-size: 2.5rem; font-weight: 700; color: var(--color-success);">{args.postman_files}</div>
113
+ <div style="color: var(--color-muted); font-size: 0.85rem;">Collections</div>
114
+ <div style="margin-top: 0.5rem; font-size: 0.9rem;"><strong>{args.postman_requests}</strong> requests • <strong>{args.postman_assertions}</strong> assertions</div>
115
+ </div>
116
+ <div class="card">
117
+ <h4 style="color: var(--color-muted); font-size: 0.85rem; text-transform: uppercase; margin-bottom: 0.875rem;">Postman-Only (New)</h4>
118
+ <div style="font-size: 2.5rem; font-weight: 700; color: var(--color-primary);">{len(postman_only)}</div>
119
+ <div style="color: var(--color-muted); font-size: 0.85rem;">Collections</div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+
124
+ <div class="section">
125
+ <div class="section-title">Detailed Mapping <span class="count">{total_source} files</span></div>
126
+ <table>
127
+ <thead><tr><th>Source File</th><th>Folder</th><th style="text-align:center;">Tests</th><th style="text-align:center;">Status</th><th>Target Collection(s)</th></tr></thead>
128
+ <tbody>
129
+ '''
130
+
131
+ for m in mappings:
132
+ badge_class = 'success' if m['status'] == 'MIGRATED' else 'warning' if m['status'] == 'PARTIAL' else 'error'
133
+ collection = m['collection'] if m['collection'] else '—'
134
+ content += f'''<tr>
135
+ <td class="code">{m['filename']}</td>
136
+ <td>{m['folder']}</td>
137
+ <td class="center">{m['test_count']}</td>
138
+ <td class="center"><span class="badge badge-{badge_class}">{m['status']}</span></td>
139
+ <td class="code">{collection}</td>
140
+ </tr>'''
141
+
142
+ content += '</tbody></table></div>'
143
+
144
+ html = get_html_template(
145
+ title="API Test Migration Report",
146
+ logo=getattr(args, 'logo', 'Quality Reports'),
147
+ content=content,
148
+ health_badge=health,
149
+ subtitle=f"Source: {os.path.basename(args.source_folder)} → Target: {os.path.basename(args.postman_folder)}",
150
+ footer_text="Generated by Migration Report Generator"
151
+ )
152
+
153
+ with open(args.output_file, 'w') as f:
154
+ f.write(html)
155
+
156
+ print(f"Report generated: {args.output_file}")