219 lines
7.8 KiB
Python
219 lines
7.8 KiB
Python
from collections import defaultdict
|
|
from datetime import datetime
|
|
import json
|
|
import argparse
|
|
import os
|
|
import re
|
|
import pytest
|
|
from pytest import ExitCode
|
|
import traceback
|
|
|
|
def stringify(obj):
|
|
if obj is None or obj == "":
|
|
return "None"
|
|
if isinstance(obj, list):
|
|
return ', '.join(stringify(e) for e in obj)
|
|
if isinstance(obj, dict):
|
|
return ', '.join(f"{k}: {stringify(v)}" for k, v in obj.items())
|
|
return f"{str(obj)}"
|
|
|
|
def normalize_nodeid(nodeid):
|
|
match = re.match(r"(tests[/\\\\].+?)\\.py::(.+?)(?:\[.*)?$", nodeid)
|
|
if match:
|
|
file_part = match.group(1).replace("/", ".").replace("\\", ".")
|
|
func_part = match.group(2)
|
|
return f"{file_part}#{func_part}"
|
|
return None
|
|
|
|
def extract_param_str_from_nodeid(nodeid):
|
|
first = nodeid.find('[')
|
|
last = nodeid.rfind(']')
|
|
if first != -1 and last != -1 and last > first:
|
|
return nodeid[first+1:last]
|
|
return None
|
|
|
|
def get_details_block(summary, body, level=0, params_str=None):
|
|
margin = 18 * level
|
|
border = f"border-left: 2px solid #eee;" if level > 0 else ""
|
|
if params_str:
|
|
summary = f"{summary} <span style='color: #888; font-size: 0.9em;'>parameters: [{params_str}]</span>"
|
|
return (f'<div style="margin-left: {margin}px; {border} padding-left: 8px;">
|
|
'
|
|
f"<details>
|
|
<summary>{summary}</summary>
|
|
|
|
"
|
|
f"{body}
|
|
"
|
|
f"</details>
|
|
"
|
|
f"</div>
|
|
|
|
")
|
|
|
|
def make_test_block(test, status, emoji, level):
|
|
nodeid = test.get("nodeid", "")
|
|
param_str = extract_param_str_from_nodeid(nodeid)
|
|
body_test = f"- **Status:** {emoji} {status}\n"
|
|
duration = test.get("call", {}).get("duration")
|
|
body_test += f"- **Duration:** {duration:.6f} s\n" if duration else "- **Duration**: None\n"
|
|
if param_str:
|
|
body_test += f"- **Parameters:** {param_str}\n"
|
|
for phase in ['setup', 'call', 'teardown']:
|
|
if phase in test:
|
|
phase_body = ""
|
|
for field, value in test[phase].items():
|
|
if value is None:
|
|
phase_body += f"- **{field.capitalize()}:** None\n"
|
|
else:
|
|
details_body = "```
|
|
" + stringify(value) + "
|
|
```
|
|
"
|
|
phase_body += get_details_block(f"📌 {field.capitalize()}", details_body, level+2)
|
|
if phase_body:
|
|
body_test += f"\n### 🔧 {phase.capitalize()} Phase\n\n" + phase_body
|
|
return get_details_block(f"{emoji} {nodeid}", body_test, level+1, params_str=param_str)
|
|
|
|
def json_to_md_nested(json_path, md_path):
|
|
with open(json_path) as f:
|
|
data = json.load(f)
|
|
|
|
with open(md_path, 'w', encoding='utf-8') as f:
|
|
f.write(f"# 🧪 Test Report\n")
|
|
f.write(f"*Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n")
|
|
|
|
known = {'summary', 'tests', 'collectors'}
|
|
|
|
if 'summary' in data:
|
|
f.write("## 📋 Summary\n")
|
|
for key, value in data['summary'].items():
|
|
f.write(f"- **{key.capitalize()}**: {stringify(value)}\n")
|
|
duration = data.get("duration")
|
|
f.write(f"- **Total Duration**: {duration:.3f}s\n" if duration else "- **Total Duration**: None\n")
|
|
f.write("\n")
|
|
|
|
if "tests" in data:
|
|
f.write("## 🔎 Tests\n")
|
|
for test in data["tests"]:
|
|
f.write(make_test_block(test, test.get("outcome", "unknown"), "✅" if test.get("outcome") == "passed" else "❌", level=0))
|
|
|
|
if "collectors" in data:
|
|
f.write("## 📚 Collected files\n")
|
|
for i, collector in enumerate(data["collectors"], 1):
|
|
nodeid = collector.get("nodeid", "unknown")
|
|
emoji = "✅" if collector.get("outcome") == "passed" else "❌"
|
|
result = collector.get("result", [])
|
|
result_block = ""
|
|
if result:
|
|
result_block += "```
|
|
"
|
|
for item in result:
|
|
if isinstance(item, dict):
|
|
for k, v in item.items():
|
|
result_block += f"{k}: {stringify(v)}\n"
|
|
else:
|
|
result_block += f"{stringify(item)}\n"
|
|
result_block += "```"
|
|
f.write(get_details_block(f"{emoji} {nodeid}", result_block, level=1))
|
|
|
|
for key, value in data.items():
|
|
if key not in known:
|
|
block = ""
|
|
if isinstance(value, list):
|
|
block += "```
|
|
"
|
|
for item in value:
|
|
block += stringify(item) + "\n"
|
|
block += "```"
|
|
else:
|
|
block = f"```
|
|
{stringify(value)}
|
|
```"
|
|
f.write(get_details_block(f"🔧 {key.capitalize()}", block, level=0))
|
|
|
|
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><summary>📋 Short test summary info</summary>\n\n" +
|
|
"```
|
|
" + "".join(short_summary_lines) + "```
|
|
" +
|
|
"</details>\n\n" +
|
|
"<details><summary>🪵 Full raw pytest log</summary>\n\n" +
|
|
"```
|
|
" + "".join(log_lines) + "```
|
|
" +
|
|
"</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}")
|
|
|
|
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("--json", required=False, help="Path to pytest-report.json")
|
|
parser.add_argument("--exit-code", type=int, default=0, help="Exit code from pytest to determine the banner.")
|
|
args = parser.parse_args()
|
|
json_to_md_nested(args.input, args.output)
|
|
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()
|