330 lines
12 KiB
Python
330 lines
12 KiB
Python
from collections import defaultdict
|
|
from datetime import datetime
|
|
import json
|
|
import argparse
|
|
import os
|
|
import re
|
|
import pytz
|
|
|
|
def stringify(obj, indent=0):
|
|
space = ' ' * indent
|
|
|
|
if obj is None or obj == "":
|
|
return "None"
|
|
|
|
if isinstance(obj, list):
|
|
if not obj:
|
|
return "[]"
|
|
return '\n'.join(
|
|
f"{space}- (vide)" if not e or (isinstance(e, dict) and not e)
|
|
else f"{space}- {stringify(e, indent + 1)}" if not isinstance(e, dict)
|
|
else f"{space}- {stringify(e, indent + 1)}"
|
|
for e in obj
|
|
)
|
|
|
|
if isinstance(obj, dict):
|
|
if not obj:
|
|
return "{}"
|
|
return '\n'.join(
|
|
f"{space}{k}: {stringify(v, indent + 1)}" if not isinstance(v, (dict, list))
|
|
else f"{space}{k}:\n{stringify(v, indent + 1)}"
|
|
for k, v in obj.items()
|
|
)
|
|
|
|
return str(obj)
|
|
|
|
def sanitize_param_value(v):
|
|
s = str(v)
|
|
return (
|
|
s.replace('&', '^')
|
|
.replace('<', '(')
|
|
.replace('>', ')')
|
|
.replace('"', '')
|
|
.replace("'", '')
|
|
.replace('`', '')
|
|
.replace('=', '≡')
|
|
.replace('\\', '/')
|
|
)
|
|
|
|
def get_clickable_block(summary, body, block_id, level=0):
|
|
"""Version avec navigation cliquable (Précédent/Accueil)"""
|
|
indent = " " * level
|
|
|
|
# Navigation
|
|
nav_links = []
|
|
if block_id > 1:
|
|
nav_links.append(f"[← Précédent](#block_{block_id-1})")
|
|
nav_links.append(f"[↑ Accueil](#top)")
|
|
|
|
return (
|
|
f"{indent}<div id='block_{block_id}' style='margin-bottom:20px;border-bottom:1px solid #eee;padding-bottom:15px'>\n"
|
|
f"{indent} <h3>{summary}</h3>\n"
|
|
f"{indent} <div>{body}</div>\n"
|
|
f"{indent} <div style='text-align:right;font-size:0.9em;margin-top:10px'>\n"
|
|
f"{indent} {' | '.join(nav_links)}\n"
|
|
f"{indent} </div>\n"
|
|
f"{indent}</div>\n"
|
|
)
|
|
|
|
def make_test_block(test, status, emoji, level, runtime_params, block_id):
|
|
nodeid = test.get("nodeid", "")
|
|
body_test = ""
|
|
summary_note = f"{emoji} #{test['global_number']}"
|
|
|
|
callspec = runtime_params.get(nodeid, {})
|
|
params = callspec.get("params", {}) if isinstance(callspec, dict) else {}
|
|
|
|
if isinstance(params, dict) and params:
|
|
param_display = ", ".join(f"{k}={sanitize_param_value(v)}" for k, v in params.items())
|
|
summary_note += f" <small style='color:#666'>[{param_display}]</small>"
|
|
|
|
if callspec:
|
|
callspec_block = "```python\n" + stringify(callspec) + "\n```"
|
|
body_test += callspec_block + "\n\n"
|
|
|
|
skip_keys = {"nodeid"}
|
|
phase_keys = [k for k in test.keys() if isinstance(test[k], dict) and k not in skip_keys]
|
|
|
|
for phase in phase_keys:
|
|
phase_body = ""
|
|
for field, value in test[phase].items():
|
|
details_body = "```python\n" + stringify(value) + "\n```" if value is not None else "None"
|
|
phase_body += f"**{field.capitalize()}:**\n{details_body}\n\n"
|
|
if phase_body:
|
|
body_test += f"### {phase.capitalize()} Phase\n{phase_body}"
|
|
|
|
return get_clickable_block(summary_note, body_test, block_id, level + 1)
|
|
|
|
def json_to_md_nested(json_path, md_path, runtime_params=None):
|
|
with open(json_path) as f:
|
|
data = json.load(f)
|
|
|
|
with open(md_path, 'w', encoding='utf-8') as f:
|
|
# En-tête avec CSS
|
|
f.write("""<style>
|
|
a { color: #0366d6; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
#top { padding-top: 20px; }
|
|
h3 { margin: 0 0 8px 0; color: #333; }
|
|
small { color: #666; }
|
|
</style>
|
|
<a id="top"></a>\n\n""")
|
|
|
|
f.write(f"# 🧪 Test Report\n")
|
|
|
|
local = pytz.timezone("Europe/Zurich")
|
|
now = datetime.now(local)
|
|
f.write(f"*Generated on {now.strftime('%Y-%m-%d %H:%M:%S %Z')}*\n\n")
|
|
|
|
general_info = {}
|
|
for k, v in data.items():
|
|
if k == "summary":
|
|
break
|
|
if k not in {"created", "exitcode"}:
|
|
general_info[k] = v
|
|
|
|
if general_info:
|
|
f.write("## 🧾 General Info\n")
|
|
for key, value in general_info.items():
|
|
f.write(f"- **{key}**: {stringify(value)}\n")
|
|
f.write("\n")
|
|
|
|
if 'summary' in data:
|
|
f.write("## 📋 Summary\n")
|
|
for key, value in data['summary'].items():
|
|
f.write(f"- **{key.capitalize()}**: {stringify(value)}\n")
|
|
f.write("\n")
|
|
|
|
|
|
if "tests" in data and "summary" in data:
|
|
test_counter = 1
|
|
for test in data["tests"]:
|
|
test['global_number'] = test_counter
|
|
test_counter += 1
|
|
|
|
f.write("## 🔎 Tests\n")
|
|
|
|
# Compter le nombre total de tests
|
|
total_tests = len(data["tests"])
|
|
current_block = 1
|
|
|
|
for test in data["tests"]:
|
|
outcome = test.get("outcome", "unknown")
|
|
emoji = "✅" if outcome == "passed" else "❌"
|
|
|
|
block = make_test_block(
|
|
test=test,
|
|
status=outcome,
|
|
emoji=emoji,
|
|
level=1,
|
|
runtime_params=runtime_params,
|
|
block_id=current_block
|
|
)
|
|
f.write(block)
|
|
current_block += 1
|
|
|
|
|
|
summary_items = list(data["summary"].items())
|
|
total_index = next((i for i, (k, _) in enumerate(summary_items) if k == "total"), len(summary_items))
|
|
status_order = [k for k, _ in summary_items[:total_index]]
|
|
|
|
tests_by_status = defaultdict(list)
|
|
for test in data["tests"]:
|
|
outcome = test.get("outcome", "unknown")
|
|
tests_by_status[outcome].append(test)
|
|
|
|
# Compter le nombre total de blocs pour la navigation
|
|
total_blocks = sum(len(tests) for tests in tests_by_status.values())
|
|
current_block = 1
|
|
|
|
# ---------- Collectors section -----------
|
|
if "collectors" in data:
|
|
f.write("## 📚 Collected files\n")
|
|
grouped = defaultdict(list)
|
|
for collector in data["collectors"]:
|
|
nodeid = collector.get("nodeid", "unknown")
|
|
path = nodeid.split("::")[0]
|
|
main
|
|
|
|
collectors_sorted = sorted(collectors, key=lambda c: c.get("nodeid", "").split("[")[0])
|
|
folder_body = ""
|
|
for collector in collectors_sorted:
|
|
outcome = collector.get("outcome", "unknown")
|
|
emoji = "✅" if outcome == "passed" else "❌"
|
|
nodeid = collector.get("nodeid", "unknown")
|
|
short_node = nodeid.split("[")[0]
|
|
|
|
body_coll = f"- **Outcome:** `{outcome}`\n"
|
|
other_keys = {k: v for k, v in collector.items() if k not in {"nodeid", "outcome"}}
|
|
if other_keys:
|
|
for k, v in other_keys.items():
|
|
block = stringify(v)
|
|
body_coll += f"- **{k}:**\n\n```python\n{block}\n```\n\n"
|
|
else:
|
|
body_coll += "- **Details:** `None`\n"
|
|
folder_body += get_details_block(f"{emoji} {short_node}", body_coll, level=2)
|
|
f.write(get_details_block(f"{folder_emoji} {folder} ({len(collectors)} tests)", folder_body + body_collectors, level=1))
|
|
|
|
# ---------- OTHER sections -----------
|
|
EXCLUDED_KEYS = {'tests', 'collectors'}
|
|
|
|
keys = list(data.keys())
|
|
if "summary" in keys:
|
|
start_index = keys.index("summary") + 1
|
|
else:
|
|
start_index = len(keys)
|
|
|
|
for key in keys[start_index:]:
|
|
if key in EXCLUDED_KEYS or not data[key]:
|
|
continue
|
|
|
|
f.write(f"## ⚠️ {key.capitalize()}\n\n")
|
|
|
|
for i, entry in enumerate(data[key], 1):
|
|
entry_body = "```\n"
|
|
if isinstance(entry, dict):
|
|
for k, v in entry.items():
|
|
entry_body += f"{k}: {v}\n"
|
|
else:
|
|
entry_body += str(entry) + "\n"
|
|
entry_body += "```\n"
|
|
f.write(get_details_block(f"{key.capitalize()} #{i}", entry_body, level=1))
|
|
|
|
|
|
def run_pytest_and_generate_banner_with_logs(md_path, log_path, exit_code):
|
|
if exit_code == 0 or exit_code == 1:
|
|
return
|
|
|
|
if exit_code == 2:
|
|
banner = (
|
|
"⚠️ **Test execution interrupted**\n\n"
|
|
"> The test run was interrupted by the user (reasons : KeyboardInterrupt or ...).\n\n"
|
|
)
|
|
elif exit_code == 3:
|
|
banner = (
|
|
"🛑 **Internal error during testing**\n\n"
|
|
"> An internal error occurred while executing the tests.\n\n"
|
|
)
|
|
elif exit_code == 4:
|
|
banner = (
|
|
"❗ **Pytest command line usage error**\n\n"
|
|
"> There was an error in how pytest was invoked.\n\n"
|
|
)
|
|
elif exit_code == 5:
|
|
banner = (
|
|
"❗ **No tests were collected**\n\n"
|
|
"> Pytest did not find any tests to run.\n\n"
|
|
)
|
|
else:
|
|
banner = (
|
|
f"❓ **Unknown pytest exit code: {exit_code}**\n\n"
|
|
"> Unexpected result during test execution.\n\n"
|
|
)
|
|
|
|
try:
|
|
with open(log_path, "r") as lf:
|
|
log_lines = lf.readlines()
|
|
except Exception as e:
|
|
print(f"❌ Could not read log file: {e}")
|
|
return
|
|
|
|
short_summary_lines = []
|
|
in_summary = False
|
|
for line in log_lines:
|
|
if "short test summary info" in line.lower():
|
|
in_summary = True
|
|
if in_summary:
|
|
short_summary_lines.append(line)
|
|
if re.match(r"=+.* in .*s =+", line):
|
|
break
|
|
|
|
try:
|
|
with open(md_path, "r") as f:
|
|
original_md = f.read()
|
|
except Exception as e:
|
|
print(f"❌ Could not read markdown report: {e}")
|
|
return
|
|
|
|
full_banner = (
|
|
banner + "<details>\n<summary>🪵 Full raw pytest log</summary>\n\n" +
|
|
"```\n" + "".join(log_lines) + "```\n</details>\n\n" +
|
|
"---\n\n"
|
|
)
|
|
|
|
try:
|
|
with open(md_path, "w") as f:
|
|
f.write(full_banner + original_md)
|
|
print("✅ Banner and log summary added to markdown report.")
|
|
except Exception as e:
|
|
print(f"❌ Failed to update markdown report: {e}")
|
|
return
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Convert JSON test results to Markdown.")
|
|
parser.add_argument("--input", required=True, help="Path to pytest JSON file")
|
|
parser.add_argument("--output", required=True, help="Path to output Markdown file")
|
|
parser.add_argument("--log", required=False, help="Path to raw pytest output log (optional)")
|
|
parser.add_argument("--params", required=False, help="Path to runtime-params.json")
|
|
parser.add_argument("--exit-code", type=int, default=0, help="Exit code from pytest to determine the banner.")
|
|
|
|
args = parser.parse_args()
|
|
|
|
runtime_params = {}
|
|
if args.params and os.path.exists(args.params):
|
|
try:
|
|
with open(args.params, "r") as f:
|
|
param_list = json.load(f)
|
|
for entry in param_list:
|
|
if "nodeid" in entry and isinstance(entry.get("callspec"), dict):
|
|
runtime_params[entry["nodeid"]] = entry["callspec"]
|
|
except Exception as e:
|
|
print(f"❌ Failed to read runtime parameters: {e}")
|
|
|
|
json_to_md_nested(args.input, args.output, runtime_params)
|
|
|
|
run_pytest_and_generate_banner_with_logs(md_path=args.output, log_path=args.log, exit_code=args.exit_code)
|
|
|
|
print(f"✅ Report generated at {args.output}")
|
|
|
|
if __name__ == "__main__":
|
|
main() |