@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.
- package/README.md +351 -0
- package/bin/shiftleft.js +95 -0
- package/package.json +57 -0
- package/src/commands/doctor.js +208 -0
- package/src/commands/init-postman.js +298 -0
- package/src/commands/init-rules.js +78 -0
- package/src/commands/link.js +172 -0
- package/src/commands/protect.js +61 -0
- package/src/commands/run-tests.js +182 -0
- package/src/commands/setup-pipeline.js +209 -0
- package/src/commands/update.js +203 -0
- package/src/index.js +4 -0
- package/src/utils/copy-tree.js +98 -0
- package/src/utils/gitignore.js +26 -0
- package/src/utils/logger.js +9 -0
- package/src/utils/manifest.js +145 -0
- package/src/utils/stack.js +80 -0
- package/src/utils/template.js +135 -0
- package/templates/AGENTS.md +109 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/jenkins/Jenkinsfile-java.groovy +432 -0
- package/templates/jenkins/Jenkinsfile-node.groovy +450 -0
- package/templates/postman/.husky/pre-commit +19 -0
- package/templates/postman/.prettierrc.json +5 -0
- package/templates/postman/README.md.ejs +147 -0
- package/templates/postman/collections/01-core.json.ejs +91 -0
- package/templates/postman/config/local.json.ejs +12 -0
- package/templates/postman/config/staging.json.ejs +26 -0
- package/templates/postman/environments/local.postman_environment.json.ejs +31 -0
- package/templates/postman/environments/staging.postman_environment.json.ejs +31 -0
- package/templates/postman/gitignore +16 -0
- package/templates/postman/npmrc +31 -0
- package/templates/postman/package.json.ejs +66 -0
- package/templates/postman/run-all-shim.sh +16 -0
- package/templates/postman/scripts/auth/generate-jwt.sh +113 -0
- package/templates/postman/scripts/auth/get-issuer-secret.sh +140 -0
- package/templates/postman/scripts/infra/start-mocks.sh +138 -0
- package/templates/postman/scripts/infra/stop-mocks.sh +43 -0
- package/templates/postman/scripts/lib/api_coverage.py +1122 -0
- package/templates/postman/scripts/lib/cleanup-reports.sh +101 -0
- package/templates/postman/scripts/lib/cleanup-stryker.sh +44 -0
- package/templates/postman/scripts/lib/report_combined.py +527 -0
- package/templates/postman/scripts/lib/report_consolidated.py +363 -0
- package/templates/postman/scripts/lib/report_generator.py +121 -0
- package/templates/postman/scripts/lib/report_migration.py +156 -0
- package/templates/postman/scripts/lib/report_mutation.py +110 -0
- package/templates/postman/scripts/lib/report_unit.py +353 -0
- package/templates/postman/scripts/lib/report_utils.py +973 -0
- package/templates/postman/scripts/report-generators/generate-consolidated-report.sh +445 -0
- package/templates/postman/scripts/report-generators/java-api-coverage-matrix.sh +257 -0
- package/templates/postman/scripts/report-generators/mutation-report.sh +672 -0
- package/templates/postman/scripts/report-generators/node-api-coverage-matrix.sh +167 -0
- package/templates/postman/scripts/report-generators/stage-report-artifacts.sh +27 -0
- package/templates/postman/scripts/run-all.sh +452 -0
- package/templates/postman/scripts/runners/run-mutation-tests.sh +113 -0
- package/templates/postman/scripts/runners/run-tests-local.sh +936 -0
- package/templates/postman/scripts/runners/run-tests-staging.sh +741 -0
- package/templates/postman-node/README.md.ejs +26 -0
- package/templates/postman-node/collections/crud/01-bootstrap.json.ejs +34 -0
- package/templates/postman-node/config/local.json.ejs +46 -0
- package/templates/postman-node/config/staging.json.ejs +31 -0
- package/templates/postman-node/local.test.env.ejs +3 -0
- package/templates/postman-node/mocks/external.js +14 -0
- package/templates/postman-node/package.json.ejs +39 -0
- package/templates/postman-node/requirements.txt +1 -0
- package/templates/postman-node/scripts/database/cleanup-mysql.sh +12 -0
- package/templates/postman-node/scripts/database/run-migrations.js +29 -0
- package/templates/postman-node/scripts/database/start-mysql.sh +34 -0
- package/templates/postman-node/scripts/database/wait-for-mysql.sh +36 -0
- package/templates/postman-node/scripts/lib/api_coverage_node.py +1137 -0
- package/templates/postman-node/scripts/lib/fetch-jwt.sh +86 -0
- package/templates/postman-node/scripts/lib/run-newman.sh +104 -0
- package/templates/postman-node/scripts/lib/setup-database.sh +55 -0
- package/templates/postman-node/scripts/lib/start-app.sh +48 -0
- package/templates/postman-node/scripts/lib/utils.sh +114 -0
- package/templates/postman-node/scripts/report-generators/stage-report-artifacts.sh +26 -0
- package/templates/postman-node/scripts/run-all.sh +303 -0
- package/templates/postman-node/scripts/runners/run-tests.sh +123 -0
- package/templates/postman-node/scripts/setup-mocks.js.ejs +29 -0
- package/templates/postman-node/stryker.config.js.ejs +51 -0
- package/templates/rules/local-test-setup.mdc +420 -0
- package/templates/rules/testing-node.mdc +66 -0
- package/templates/rules/testing.mdc +248 -0
- package/templates/skills/_shared/postman-standards.md +380 -0
- package/templates/skills/enhance-test-pipeline/SKILL-java.md +483 -0
- package/templates/skills/enhance-test-pipeline/SKILL-node.md +431 -0
- package/templates/skills/enhance-test-pipeline/SKILL.md +9 -0
- package/templates/skills/review-test-suite/SKILL-java.md +137 -0
- package/templates/skills/review-test-suite/SKILL-node.md +78 -0
- package/templates/skills/review-test-suite/SKILL.md +9 -0
- package/templates/skills/run-test-suite/SKILL-java.md +186 -0
- package/templates/skills/run-test-suite/SKILL-node.md +191 -0
- package/templates/skills/run-test-suite/SKILL.md +9 -0
- package/templates/skills/setup-api-tests/SKILL-java.md +1094 -0
- package/templates/skills/setup-api-tests/SKILL-node.md +141 -0
- package/templates/skills/setup-api-tests/SKILL.md +9 -0
- package/templates/skills/setup-mutation-tests/SKILL-java.md +303 -0
- package/templates/skills/setup-mutation-tests/SKILL-node.md +408 -0
- package/templates/skills/setup-mutation-tests/SKILL.md +9 -0
- package/templates/skills/setup-test-pipeline/SKILL-java.md +454 -0
- package/templates/skills/setup-test-pipeline/SKILL-node.md +318 -0
- package/templates/skills/setup-test-pipeline/SKILL.md +9 -0
- package/templates/skills/write-api-tests/SKILL-java.md +115 -0
- package/templates/skills/write-api-tests/SKILL-node.md +83 -0
- package/templates/skills/write-api-tests/SKILL.md +9 -0
- 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}")
|