This commit is contained in:
+59
-42
@@ -18,7 +18,7 @@ def stringify(obj):
|
||||
return str(obj)
|
||||
|
||||
def normalize_nodeid(nodeid):
|
||||
match = re.match(r"(tests[/\\\\].+?)\\.py::(.+?)(?:\[.*)?$", nodeid)
|
||||
match = re.match(r"(tests[/\\].+?)\.py::(.+?)(?:\[.*)?$", nodeid)
|
||||
if match:
|
||||
file_part = match.group(1).replace("/", ".").replace("\\", ".")
|
||||
func_part = match.group(2)
|
||||
@@ -34,77 +34,87 @@ def extract_param_str_from_nodeid(nodeid):
|
||||
|
||||
def get_details_block(summary, body, level=0, params_str=None):
|
||||
indent = " " * level
|
||||
bullet = f"{indent}- "
|
||||
if params_str:
|
||||
summary += f' <span style="color: #888; font-size: 0.9em;">parameters: [{params_str}]</span>'
|
||||
return (
|
||||
f"{bullet}<details>\n"
|
||||
f"{indent} <summary>{summary}</summary>\n\n"
|
||||
+ "\n".join(f"{indent} {line}" for line in body.strip().splitlines()) + "\n"
|
||||
f"{indent}</details>\n"
|
||||
)
|
||||
summary = f"{summary} [parameters: {params_str}]"
|
||||
return f"{indent}- {summary}\n{body}"
|
||||
|
||||
def make_test_block(test, status, emoji, level):
|
||||
nodeid = test.get("nodeid", "")
|
||||
param_str = extract_param_str_from_nodeid(nodeid)
|
||||
|
||||
body_test = ""
|
||||
|
||||
if param_str:
|
||||
body_test += f"- **Parameters:** `{param_str}`\n"
|
||||
body_test += f"{' '*(level+1)}- Parameters: `{param_str}`\n"
|
||||
|
||||
skip_keys = set(["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():
|
||||
if value is None:
|
||||
phase_body += f"- **{field.capitalize()}:** None\n"
|
||||
phase_body += f"{' '*(level+3)}- {field.capitalize()}: None\n"
|
||||
else:
|
||||
details_body = "```\n" + stringify(value) + "\n```"
|
||||
phase_body += get_details_block(f"📌 {field.capitalize()}", details_body, level+2)
|
||||
details_body = f"{' '*(level+4)}```\n{' '*(level+4)}{stringify(value)}\n{' '*(level+4)}```\n"
|
||||
phase_body += f"{' '*(level+3)}- {field.capitalize()}:\n{details_body}"
|
||||
if phase_body:
|
||||
body_test += f"\n### 🔧 {phase.capitalize()} Phase\n\n" + phase_body
|
||||
body_test += f"{' '*(level+2)}- {phase.capitalize()} Phase:\n{phase_body}"
|
||||
|
||||
return get_details_block(f"{emoji} #{test['global_number']}", 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")
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
for status in status_order:
|
||||
if status not in tests_by_status:
|
||||
continue
|
||||
|
||||
count = len(tests_by_status[status])
|
||||
emoji = "✅" if status == "passed" else "❌"
|
||||
status_label = status.capitalize().replace('_', ' ')
|
||||
|
||||
body_status = ""
|
||||
grouped = defaultdict(lambda: defaultdict(list))
|
||||
for test in tests_by_status[status]:
|
||||
@@ -113,6 +123,7 @@ def json_to_md_nested(json_path, md_path):
|
||||
filename = parts[0].replace("tests\\", "").replace("tests/", "")
|
||||
funcname = parts[1].split("[")[0]
|
||||
grouped[filename][funcname].append(test)
|
||||
|
||||
for filename, funcs in grouped.items():
|
||||
body_file = ""
|
||||
for funcname, tests in funcs.items():
|
||||
@@ -122,6 +133,7 @@ def json_to_md_nested(json_path, md_path):
|
||||
body_file += get_details_block(f"🔧 Function: `{funcname}`", body_func, level=2)
|
||||
body_status += get_details_block(f"📁 {filename}", body_file, level=1)
|
||||
f.write(get_details_block(f"{emoji} {status_label} ({count})", body_status, level=0))
|
||||
|
||||
if "collectors" in data:
|
||||
f.write("## 📚 Collected files\n")
|
||||
grouped = defaultdict(list)
|
||||
@@ -130,9 +142,11 @@ def json_to_md_nested(json_path, md_path):
|
||||
path = nodeid.split("::")[0]
|
||||
main_folder = path.split("/")[0] if "/" in path else path
|
||||
grouped[main_folder].append(collector)
|
||||
|
||||
for folder, collectors in grouped.items():
|
||||
has_fail = any(c.get("outcome") != "passed" for c in collectors)
|
||||
folder_emoji = "✅" if not has_fail else "❌"
|
||||
|
||||
body_collectors = ""
|
||||
outputs = []
|
||||
for collector in collectors:
|
||||
@@ -141,15 +155,17 @@ def json_to_md_nested(json_path, md_path):
|
||||
short_node = nodeid.split("[")[0]
|
||||
results = collector.get("result", [])
|
||||
if outcome != "passed" and results:
|
||||
outputs.append(f"### ❌ {short_node}\n```\n" + "\n".join(
|
||||
outputs.append(f"{' '*(level+2)}- ❌ {short_node}\n{' '*(level+3)}```\n{' '*(level+3)}" + "\n".join(
|
||||
f"{k}: {v}" if isinstance(item, dict) else str(item)
|
||||
for item in results
|
||||
for k, v in item.items() if isinstance(item, dict)
|
||||
) + "\n```")
|
||||
) + f"\n{' '*(level+3)}```\n")
|
||||
|
||||
if outputs:
|
||||
body_collectors += "### 🧾 Error or Result Summary\n\n"
|
||||
body_collectors += f"{' '*(level+1)}- Error or Result Summary:\n"
|
||||
for out in outputs:
|
||||
body_collectors += out + "\n"
|
||||
|
||||
collectors_sorted = sorted(collectors, key=lambda c: c.get("nodeid", "").split("[")[0])
|
||||
folder_body = ""
|
||||
for collector in collectors_sorted:
|
||||
@@ -157,38 +173,37 @@ def json_to_md_nested(json_path, md_path):
|
||||
emoji = "✅" if outcome == "passed" else "❌"
|
||||
nodeid = collector.get("nodeid", "unknown")
|
||||
short_node = nodeid.split("[")[0]
|
||||
body_coll = f"- **Outcome:** `{outcome}`\n"
|
||||
|
||||
body_coll = f"{' '*(level+2)}- Outcome: `{outcome}`\n"
|
||||
other_keys = {k: v for k, v in collector.items() if k not in {"nodeid", "outcome"}}
|
||||
if other_keys:
|
||||
body_coll += "- **Details:**\n```
|
||||
"
|
||||
body_coll += f"{' '*(level+2)}- Details:\n{' '*(level+3)}```\n"
|
||||
for k, v in other_keys.items():
|
||||
body_coll += f"{k}:\n"
|
||||
body_coll += f"{' '*(level+3)}{k}:\n"
|
||||
if v is None:
|
||||
body_coll += " None\n"
|
||||
body_coll += f"{' '*(level+3)} None\n"
|
||||
else:
|
||||
body_coll += stringify(v) + "\n"
|
||||
body_coll += "\n"
|
||||
body_coll += "```
|
||||
"
|
||||
body_coll += f"{' '*(level+3)}{stringify(v)}\n"
|
||||
body_coll += f"\n{' '*(level+3)}"
|
||||
body_coll += f"```\n"
|
||||
else:
|
||||
body_coll += "- **Details:** `None`\n"
|
||||
body_coll += f"{' '*(level+2)}- 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))
|
||||
|
||||
if 'warnings' in data and data['warnings']:
|
||||
f.write("## ⚠️ Warnings\n\n")
|
||||
for i, warning in enumerate(data['warnings'], 1):
|
||||
warn_body = "```
|
||||
"
|
||||
warn_body = f"{' '*(level+1)}```\n"
|
||||
for k, v in warning.items():
|
||||
warn_body += f"{k}: {v}\n"
|
||||
warn_body += "```
|
||||
"
|
||||
warn_body += f"{' '*(level+1)}{k}: {v}\n"
|
||||
warn_body += f"{' '*(level+1)}```\n"
|
||||
f.write(get_details_block(f"Warning #{i}", warn_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"
|
||||
@@ -214,12 +229,14 @@ def run_pytest_and_generate_banner_with_logs(md_path, log_path, exit_code):
|
||||
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:
|
||||
@@ -229,24 +246,21 @@ def run_pytest_and_generate_banner_with_logs(md_path, log_path, exit_code):
|
||||
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>📋 Short test summary info</summary>\n\n" +
|
||||
"```
|
||||
" + "".join(short_summary_lines) + "```
|
||||
</details>\n\n" +
|
||||
"<details>\n<summary>🪵 Full raw pytest log</summary>\n\n" +
|
||||
"```
|
||||
" + "".join(log_lines) + "```
|
||||
</details>\n\n" +
|
||||
"- 📋 Short test summary info:\n ```\n" + "".join(short_summary_lines) + " ```\n\n" +
|
||||
"- 🪵 Full raw pytest log:\n ```\n" + "".join(log_lines) + " ```\n\n" +
|
||||
"---\n\n"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(md_path, "w") as f:
|
||||
f.write(full_banner + original_md)
|
||||
@@ -254,17 +268,20 @@ def run_pytest_and_generate_banner_with_logs(md_path, log_path, exit_code):
|
||||
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("--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__":
|
||||
|
||||
Reference in New Issue
Block a user