88import html
99import json
1010from pathlib import Path
11+ import re
1112import textwrap
1213import xml .etree .ElementTree as ET
1314
1415
16+ def _infer_file_from_classname (classname : str ) -> str :
17+ if not classname :
18+ return ""
19+ return f"{ classname .replace ('.' , '/' )} .py"
20+
21+
22+ def _infer_source_from_detail (detail : str , fallback_file : str , fallback_line : str ) -> tuple [str , str ]:
23+ match = re .search (r"(tests/[^:\s]+\.py):(\d+)" , detail )
24+ if match :
25+ return match .group (1 ), match .group (2 )
26+ return fallback_file , fallback_line
27+
28+
1529def _parse_junit (junit_path : Path ) -> tuple [list [dict ], dict ]:
30+ if not junit_path .exists ():
31+ return [
32+ {
33+ "id" : "workflow::pytest-results" ,
34+ "classname" : "workflow" ,
35+ "name" : "pytest-results" ,
36+ "status" : "error" ,
37+ "file" : "" ,
38+ "line" : "" ,
39+ "duration" : 0.0 ,
40+ "detail" : f"JUnit XML was not found at { junit_path } . The test command may not have started or may have failed before pytest wrote results." ,
41+ }
42+ ], {"total" : 1 , "passed" : 0 , "failed" : 0 , "error" : 1 , "skipped" : 0 }
43+
1644 tree = ET .parse (junit_path )
1745 root = tree .getroot ()
1846
@@ -22,7 +50,7 @@ def _parse_junit(junit_path: Path) -> tuple[list[dict], dict]:
2250 for case in root .iter ("testcase" ):
2351 classname = case .attrib .get ("classname" , "" ).strip ()
2452 name = case .attrib .get ("name" , "" ).strip ()
25- file_path = case .attrib .get ("file" , "" ).strip ()
53+ file_path = case .attrib .get ("file" , "" ).strip () or _infer_file_from_classname ( classname )
2654 line = case .attrib .get ("line" , "" ).strip ()
2755 duration = float (case .attrib .get ("time" , "0" ) or 0 )
2856
@@ -44,6 +72,8 @@ def _parse_junit(junit_path: Path) -> tuple[list[dict], dict]:
4472 status = "skipped"
4573 detail = (skipped .text or skipped .attrib .get ("message" , "" )).strip ()
4674
75+ file_path , line = _infer_source_from_detail (detail , file_path , line )
76+
4777 summary ["total" ] += 1
4878 summary [status ] += 1
4979
@@ -131,10 +161,19 @@ def _render_run_page(
131161 "</section>"
132162 )
133163
134- pytest_html_link = (
135- f"<a class='button' href='{ html .escape (pytest_report_relpath )} '>Open rich pytest-html report</a>"
164+ rich_report_panel = (
165+ "<details class='card rich-report' open>"
166+ "<summary><span>Rich pytest report</span><small>Click to collapse</small></summary>"
167+ f"<iframe src='{ html .escape (pytest_report_relpath )} ' title='Rich pytest-html report'></iframe>"
168+ f"<p><a class='button' href='{ html .escape (pytest_report_relpath )} '>Open rich report in a new tab</a></p>"
169+ "</details>"
136170 if pytest_report_relpath
137- else ""
171+ else (
172+ "<section class='card rich-report empty'>"
173+ "<h2>Rich pytest report unavailable</h2>"
174+ "<p>The pytest-html artifact was not produced for this run.</p>"
175+ "</section>"
176+ )
138177 )
139178
140179 fail_list = "" .join (
@@ -152,24 +191,39 @@ def _render_run_page(
152191 <meta name="viewport" content="width=device-width, initial-scale=1" />
153192 <title>Test Run { html .escape (run_meta ["run_id" ])} - { html .escape (run_meta ["workflow" ])} </title>
154193 <style>
155- body {{ font-family: Inter, ui-sans-serif, system-ui, sans-serif; margin: 0; background:#0b1020; color:#e6edf3; }}
156- .wrap {{ max-width: 1200px; margin: 0 auto; padding: 24px; }}
157- .grid {{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 12px; }}
158- .card {{ background:#111827; border:1px solid #334155; border-radius:12px; padding:16px; margin-top: 14px; }}
159- .badge {{ font-size: 12px; border-radius: 999px; padding: 3px 10px; font-weight: 600; text-transform: uppercase; }}
194+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap');
195+ :root {{ color-scheme: dark; --bg:#070b16; --panel:rgba(15,23,42,.74); --panel-strong:rgba(15,23,42,.92); --border:rgba(148,163,184,.22); --text:#e5eefb; --muted:#9fb0c6; --accent:#7dd3fc; --accent-strong:#2563eb; }}
196+ * {{ box-sizing: border-box; }}
197+ body {{ font-family: Inter, ui-sans-serif, system-ui, sans-serif; margin: 0; background: radial-gradient(circle at 15% 0%, rgba(59,130,246,.24), transparent 32rem), radial-gradient(circle at 85% 10%, rgba(14,165,233,.18), transparent 28rem), linear-gradient(180deg, #020617, var(--bg)); color:var(--text); }}
198+ .wrap {{ max-width: 1320px; margin: 0 auto; padding: 28px; }}
199+ h1 {{ font-size: clamp(2rem, 4vw, 4rem); letter-spacing: -.055em; margin: 10px 0 4px; }}
200+ h2 {{ font-size: 1.05rem; letter-spacing: -.02em; }}
201+ .subnav {{ color: var(--muted); margin-bottom: 18px; }}
202+ .grid {{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; }}
203+ .card {{ background:linear-gradient(180deg, var(--panel), rgba(15,23,42,.56)); border:1px solid var(--border); border-radius:18px; padding:18px; margin-top: 16px; box-shadow: 0 18px 55px rgba(2,6,23,.32); backdrop-filter: blur(18px); }}
204+ .grid .card p {{ font-size: 2rem; font-weight: 800; margin: 0; letter-spacing: -.05em; }}
205+ .badge {{ font-size: 11px; border-radius: 999px; padding: 4px 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }}
160206 table {{ width:100%; border-collapse: collapse; }}
161- th, td {{ text-align:left; border-bottom:1px solid #334155; padding: 8px; font-size: 14px; vertical-align: top; }}
162- a {{ color:#93c5fd; text-decoration: none; }}
207+ th, td {{ text-align:left; border-bottom:1px solid rgba(148,163,184,.18); padding: 10px 8px; font-size: 14px; vertical-align: top; }}
208+ th {{ color:#b8c7dc; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }}
209+ a {{ color:var(--accent); text-decoration: none; }}
163210 a:hover {{ text-decoration: underline; }}
164- .button {{ display:inline-block; margin: 8px 0 0; background:#1d4ed8; color:white; padding:8px 12px; border-radius:8px; }}
165- pre {{ white-space: pre-wrap; background:#020617; border-radius:8px; padding:12px; border:1px solid #334155; overflow-x:auto; }}
211+ .button {{ display:inline-block; margin: 8px 0 0; background:linear-gradient(135deg, var(--accent-strong), #0891b2); color:white; padding:10px 14px; border-radius:12px; font-weight:700; box-shadow: 0 10px 30px rgba(37,99,235,.26); }}
212+ pre {{ font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; background:#020617; border-radius:14px; padding:14px; border:1px solid var(--border); overflow-x:auto; }}
213+ .rich-report {{ padding: 0; overflow: hidden; }}
214+ .rich-report summary {{ cursor:pointer; list-style:none; display:flex; align-items:center; justify-content:space-between; gap: 16px; padding: 16px 18px; border-bottom:1px solid var(--border); font-weight:800; letter-spacing:-.02em; }}
215+ .rich-report summary::-webkit-details-marker {{ display:none; }}
216+ .rich-report summary small {{ color:var(--muted); font-weight:600; letter-spacing:0; }}
217+ .rich-report iframe {{ display:block; width:100%; min-height: 780px; border:0; background:#fff; }}
218+ .rich-report p {{ margin: 12px 18px 18px; }}
219+ .empty {{ padding: 18px; }}
166220 </style>
167221</head>
168222<body>
169223 <div class="wrap">
170224 <h1>Run #{ html .escape (str (run_meta ["run_number" ]))} (attempt { html .escape (str (run_meta ["run_attempt" ]))} )</h1>
171- <p><a href="../../index.html">Back to dashboard</a> · <a href="{ html .escape (run_meta ["run_url" ])} ">GitHub Actions run</a></p>
172- { pytest_html_link }
225+ <p class="subnav" ><a href="../../index.html">Back to dashboard</a> · <a href="{ html .escape (run_meta ["run_url" ])} ">GitHub Actions run</a></p>
226+ { rich_report_panel }
173227 <div class="grid">
174228 <div class="card"><h2>Total</h2><p>{ run_meta ["summary" ]["total" ]} </p></div>
175229 <div class="card"><h2>Passed</h2><p>{ by_status ["passed" ]} </p></div>
@@ -275,20 +329,24 @@ def _render_dashboard(site_dir: Path, runs: list[dict]) -> None:
275329 <meta name="viewport" content="width=device-width, initial-scale=1" />
276330 <title>tbsim validation dashboard</title>
277331 <style>
278- :root {{ color-scheme: dark; }}
279- body {{ font-family: Inter, ui-sans-serif, system-ui, sans-serif; margin: 0; background: linear-gradient(180deg, #030712, #0f172a); color:#e2e8f0; }}
280- .wrap {{ max-width: 1400px; margin: 0 auto; padding: 24px; }}
281- .hero {{ background: rgba(15, 23, 42, 0.7); border:1px solid #334155; border-radius: 14px; padding: 20px; }}
282- h1, h2 {{ margin: 8px 0 12px; }}
283- p {{ color:#cbd5e1; }}
284- .badge {{ font-size: 12px; border-radius: 999px; padding: 3px 10px; font-weight: 600; text-transform: uppercase; }}
285- .card {{ background: rgba(15, 23, 42, 0.72); border:1px solid #334155; border-radius: 12px; padding: 16px; margin-top: 14px; }}
332+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap');
333+ :root {{ color-scheme: dark; --bg:#070b16; --panel:rgba(15,23,42,.74); --border:rgba(148,163,184,.22); --text:#e5eefb; --muted:#9fb0c6; --accent:#7dd3fc; }}
334+ * {{ box-sizing: border-box; }}
335+ body {{ font-family: Inter, ui-sans-serif, system-ui, sans-serif; margin: 0; background: radial-gradient(circle at 15% 0%, rgba(59,130,246,.24), transparent 32rem), radial-gradient(circle at 85% 10%, rgba(14,165,233,.18), transparent 28rem), linear-gradient(180deg, #020617, var(--bg)); color:var(--text); }}
336+ .wrap {{ max-width: 1440px; margin: 0 auto; padding: 28px; }}
337+ .hero {{ background:linear-gradient(135deg, rgba(30,41,59,.88), rgba(15,23,42,.72)); border:1px solid var(--border); border-radius: 22px; padding: 26px; box-shadow: 0 24px 70px rgba(2,6,23,.36); backdrop-filter: blur(18px); }}
338+ h1 {{ font-size: clamp(2.2rem, 4vw, 4.6rem); letter-spacing: -.06em; margin: 6px 0 10px; }}
339+ h2 {{ margin: 8px 0 12px; letter-spacing: -.025em; }}
340+ p {{ color:var(--muted); }}
341+ .badge {{ font-size: 11px; border-radius: 999px; padding: 4px 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }}
342+ .card {{ background:linear-gradient(180deg, var(--panel), rgba(15,23,42,.56)); border:1px solid var(--border); border-radius: 18px; padding: 18px; margin-top: 16px; box-shadow: 0 18px 55px rgba(2,6,23,.32); backdrop-filter: blur(18px); }}
286343 table {{ width:100%; border-collapse: collapse; }}
287- th, td {{ text-align:left; border-bottom:1px solid #334155; padding: 8px; font-size: 13px; }}
288- a {{ color:#93c5fd; text-decoration: none; }}
344+ th, td {{ text-align:left; border-bottom:1px solid rgba(148,163,184,.18); padding: 10px 8px; font-size: 13px; }}
345+ th {{ color:#b8c7dc; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }}
346+ a {{ color:var(--accent); text-decoration: none; }}
289347 a:hover {{ text-decoration: underline; }}
290348 .matrix {{ overflow-x: auto; }}
291- .dot {{ width: 11px ; height: 11px ; border-radius: 999px; display: inline-block; }}
349+ .dot {{ width: 12px ; height: 12px ; border-radius: 999px; display: inline-block; box-shadow: 0 0 0 3px rgba(255,255,255,.05) ; }}
292350 .small th, .small td {{ padding: 6px; font-size: 12px; }}
293351 </style>
294352</head>
@@ -351,7 +409,16 @@ def main() -> None:
351409
352410 # Copy raw artifacts for direct inspection.
353411 junit_target = run_dir / "junit.xml"
354- junit_target .write_text (args .junit .read_text (encoding = "utf-8" ), encoding = "utf-8" )
412+ if args .junit .exists ():
413+ junit_target .write_text (args .junit .read_text (encoding = "utf-8" ), encoding = "utf-8" )
414+ else :
415+ junit_target .write_text (
416+ "<testsuites><testsuite errors='1' failures='0' skipped='0' tests='1'>"
417+ "<testcase classname='workflow' name='pytest-results'>"
418+ "<error message='JUnit XML was not produced'>The pytest JUnit XML artifact was not found.</error>"
419+ "</testcase></testsuite></testsuites>\n " ,
420+ encoding = "utf-8" ,
421+ )
355422
356423 pytest_html_relpath = None
357424 if args .pytest_html and args .pytest_html .exists ():
0 commit comments