11#!/usr/bin/env python3
22"""
3- Parse surefire XML reports from all shards and print an http4s-vs-Lift
4- per-test speed table to stdout (plain text) and, if GITHUB_STEP_SUMMARY
5- is set, append a markdown version to that file.
3+ Parse surefire XML reports from all shards and print a per-test speed table.
4+
5+ The Lift -> http4s migration is complete: *every* API version (v1.2.1 through
6+ v7.0.0) is served by http4s. There is no Lift HTTP code left, so the old
7+ "http4s vs Lift" split is meaningless. The meaningful axis now is **execution
8+ model**:
9+
10+ * unit/pure — no embedded server; pure logic / JSON-factory / route-matcher
11+ / middleware tests. These are the speed win of the migration.
12+ * integration — boots a real server (test class extends ServerSetup) or a
13+ self-started http4s server; pays DB/HTTP cost per test.
14+
15+ Two tables are printed:
16+ 1. By execution model (unit/pure vs integration) — the migration KPI.
17+ 2. By API version (API v1 .. v7, all http4s) — per-version cost.
18+
19+ A suite counts as integration when its test class extends `ServerSetup`
20+ (detected by scanning obp-api/src/test/scala) or is one of the self-starting
21+ http4s server suites in HTTP4S_INTEGRATION_SUITES. Everything else is unit/pure.
622
723Usage:
824 python3 test_speed_report.py <reports-root-dir>
925
10- <reports-root-dir> should contain the extracted artifacts from all shards,
11- e.g. after downloading test-reports-shard{1,2,3} into one directory.
26+ <reports-root-dir> should contain the extracted artifacts from all shards.
27+ Override the source root (used only for integration detection) with
28+ OBP_TEST_SRC_ROOT; if sources are not found, the execution-model split degrades
29+ gracefully (suites counted as integration) and the by-version table still prints.
1230"""
1331
32+ from __future__ import annotations
33+
1434import os
35+ import re
1536import sys
1637import glob
1738import xml .etree .ElementTree as ET
1839from collections import defaultdict
1940
20- # ---------------------------------------------------------------------------
21- # Classification
22- # ---------------------------------------------------------------------------
23-
24- # These suites run a real embedded server — they pay the same DB/HTTP cost
25- # as Lift integration tests.
41+ # Self-starting http4s integration suites that boot a server WITHOUT extending
42+ # ServerSetup (so the source scan can't detect them) — list them explicitly.
2643HTTP4S_INTEGRATION_SUITES = {
2744 "code.api.v7_0_0.Http4s700RoutesTest" ,
2845 "code.api.v7_0_0.Http4s700TransactionTest" ,
29- "code.api.http4sbridge.Http4sLiftBridgePropertyTest" ,
3046 "code.api.http4sbridge.Http4sServerIntegrationTest" ,
3147 "code.api.v5_0_0.Http4s500SystemViewsTest" ,
3248}
3349
50+ DEFAULT_SRC_ROOT = "obp-api/src/test/scala"
51+
52+
53+ # ---------------------------------------------------------------------------
54+ # Integration detection by source scan (does the test class extend ServerSetup?)
55+ # ---------------------------------------------------------------------------
56+
57+ def build_integration_map (src_root ):
58+ """Return ({fqClassName: extendsServerSetup}, scan_ok)."""
59+ fqmap = {}
60+ if not os .path .isdir (src_root ):
61+ return fqmap , False
62+ for root , _dirs , files in os .walk (src_root ):
63+ for fname in files :
64+ if not fname .endswith (".scala" ):
65+ continue
66+ try :
67+ with open (os .path .join (root , fname ), encoding = "utf-8" , errors = "ignore" ) as fh :
68+ txt = fh .read ()
69+ except OSError :
70+ continue
71+ pm = re .search (r'^\s*package\s+([\w.]+)' , txt , re .M )
72+ pkg = pm .group (1 ) if pm else ""
73+ # For each `class X ... {`, inspect the parents portion (everything
74+ # up to the first brace) and check whether it mentions ServerSetup.
75+ for cm in re .finditer (r'\bclass\s+(\w+)\b(.*?)\{' , txt , re .S ):
76+ cls , parents = cm .group (1 ), cm .group (2 )
77+ fqmap [f"{ pkg } .{ cls } " ] = ("ServerSetup" in parents )
78+ return fqmap , True
79+
80+
81+ def is_integration (fq , fqmap ):
82+ if fq in HTTP4S_INTEGRATION_SUITES :
83+ return True
84+ if fq in fqmap :
85+ return fqmap [fq ]
86+ # Unknown (class/file-name mismatch or degraded scan): default to
87+ # integration so we never overstate the unit/pure win.
88+ return True
3489
35- def categorize (suite_name : str ) -> str | None :
36- """Return a display category or None to exclude from the table."""
37- # http4s integration (real server)
38- if suite_name in HTTP4S_INTEGRATION_SUITES :
39- return "http4s v7 — integration"
4090
41- # http4s unit/pure (no server) — everything http4s-flavoured that isn't
42- # in the integration set above
43- if (
44- "Http4s" in suite_name
45- or "http4s" in suite_name
46- or "v7_0_0" in suite_name
47- or suite_name .startswith ("code.api.util.http4s." )
48- or suite_name .startswith ("code.api.berlin.group.v2.Http4sBGv2" )
49- ):
50- return "http4s v7 — unit/pure"
91+ # ---------------------------------------------------------------------------
92+ # API version from the suite's package
93+ # ---------------------------------------------------------------------------
5194
52- # Lift versions
53- for v in ("v6_0_0" , "v5_1_0" , "v5_0_0" , "v4_0_0" , "v3_1_0" , "v3_0_0" ,
54- "v2_2_0" , "v2_1_0" , "v2_0_0" , "v1_4_0" , "v1_3_0" , "v1_2_1" ):
55- if v in suite_name :
56- major = v [1 ] # "1" … "6"
57- return f"Lift v{ major } "
95+ _VERSIONS = ("v7_0_0" , "v6_0_0" , "v5_1_0" , "v5_0_0" , "v4_0_0" , "v3_1_0" , "v3_0_0" ,
96+ "v2_2_0" , "v2_1_0" , "v2_0_0" , "v1_4_0" , "v1_3_0" , "v1_2_1" )
5897
59- return None # exclude (util, berlin group non-http4s, etc.)
98+
99+ def api_version (fq ):
100+ for v in _VERSIONS :
101+ if v in fq :
102+ return f"API v{ v [1 ]} " # "1" .. "7"
103+ return "other"
60104
61105
62106# ---------------------------------------------------------------------------
63107# Parse
64108# ---------------------------------------------------------------------------
65109
66- def collect (reports_root : str ) -> dict :
67- stats = defaultdict (lambda : {"tests" : 0 , "time" : 0.0 })
110+ def collect (reports_root , fqmap ):
111+ by_model = defaultdict (lambda : {"tests" : 0 , "time" : 0.0 })
112+ by_version = defaultdict (lambda : {"tests" : 0 , "time" : 0.0 })
68113
69114 pattern = os .path .join (reports_root , "**" , "TEST-*.xml" )
70115 for path in glob .glob (pattern , recursive = True ):
71116 try :
72117 root = ET .parse (path ).getroot ()
73- name = root .get ("name" , "" )
118+ name = root .get ("name" , "" )
74119 tests = int (root .get ("tests" , 0 ))
75- time = float (root .get ("time" , 0 ))
120+ t = float (root .get ("time" , 0 ))
76121 if tests == 0 :
77122 continue
78- cat = categorize (name )
79- if cat is None :
80- continue
81- stats [cat ]["tests" ] += tests
82- stats [cat ]["time" ] += time
123+ model = "integration" if is_integration (name , fqmap ) else "unit/pure"
124+ by_model [model ]["tests" ] += tests
125+ by_model [model ]["time" ] += t
126+ ver = api_version (name )
127+ by_version [ver ]["tests" ] += tests
128+ by_version [ver ]["time" ] += t
83129 except Exception :
84130 pass
85131
86- return stats
132+ return by_model , by_version
87133
88134
89135# ---------------------------------------------------------------------------
90136# Render
91137# ---------------------------------------------------------------------------
92138
93- CATEGORY_ORDER = [
94- "http4s v7 — unit/pure" ,
95- "http4s v7 — integration" ,
96- "Lift v6" ,
97- "Lift v5" ,
98- "Lift v4" ,
99- "Lift v3" ,
100- "Lift v2" ,
101- "Lift v1" ,
102- ]
103-
104-
105- def render_plain (stats : dict ) -> str :
106- col_w = [25 , 7 , 12 , 10 ]
107- sep = "+-" + "-+-" .join ("-" * w for w in col_w ) + "-+"
108- hdr = "| " + " | " .join (
109- h .center (w ) for h , w in zip (
110- ["Category" , "Tests" , "Total time" , "Avg/test" ], col_w
111- )
112- ) + " |"
139+ MODEL_ORDER = ["unit/pure" , "integration" ]
140+ VERSION_ORDER = ["API v7" , "API v6" , "API v5" , "API v4" , "API v3" ,
141+ "API v2" , "API v1" , "other" ]
142+
113143
144+ def _table (stats , order , col_w = (24 , 7 , 12 , 10 )):
145+ sep = "+-" + "-+-" .join ("-" * w for w in col_w ) + "-+"
146+ hdr = "| " + " | " .join (h .center (w ) for h , w in zip (
147+ ["Category" , "Tests" , "Total time" , "Avg/test" ], col_w )) + " |"
114148 lines = [sep , hdr , sep ]
115- for cat in CATEGORY_ORDER :
149+ for cat in order :
116150 if cat not in stats :
117151 continue
118- d = stats [cat ]
152+ d = stats [cat ]
119153 avg = d ["time" ] / d ["tests" ] if d ["tests" ] else 0
120- row = "| " + " | " .join ([
154+ lines . append ( "| " + " | " .join ([
121155 cat .ljust (col_w [0 ]),
122156 str (d ["tests" ]).rjust (col_w [1 ]),
123157 f"{ d ['time' ]:.1f} s" .rjust (col_w [2 ]),
124158 f"{ avg :.3f} s" .rjust (col_w [3 ]),
125- ]) + " |"
126- lines .append (row )
159+ ]) + " |" )
127160 lines .append (sep )
128161 return "\n " .join (lines )
129162
130163
131- def render_markdown (stats : dict ) -> str :
132- rows = ["## http4s v7 vs Lift — per-test speed" ,
133- "" ,
134- "| Category | Tests | Total time | Avg/test |" ,
135- "|---|---:|---:|---:|" ]
136- for cat in CATEGORY_ORDER :
137- if cat not in stats :
164+ def render_plain (by_model , by_version , scan_ok ):
165+ out = ["All API versions (v1-v7) are served by http4s — the split is by" ,
166+ "execution model, not framework.\n " ,
167+ "By execution model (unit/pure = no server, integration = embedded server):" ]
168+ if not scan_ok :
169+ out .append (" (source scan unavailable — suites counted as integration)" )
170+ out .append (_table (by_model , MODEL_ORDER ))
171+ out += ["" , "By API version:" , _table (by_version , VERSION_ORDER )]
172+
173+ u = by_model .get ("unit/pure" )
174+ i = by_model .get ("integration" )
175+ if u and i and u ["tests" ] and i ["tests" ]:
176+ ua = u ["time" ] / u ["tests" ]
177+ ia = i ["time" ] / i ["tests" ]
178+ if ua > 0 :
179+ out += ["" ,
180+ f"--> unit/pure tests are { ia / ua :.0f} x faster per test than "
181+ f"integration tests ({ ua :.3f} s vs { ia :.3f} s)." ]
182+ return "\n " .join (out )
183+
184+
185+ def render_markdown (by_model , by_version , scan_ok ):
186+ rows = ["## Per-test speed — all endpoints served by http4s" , "" ,
187+ "_All API versions (v1-v7) run on http4s. The split below is by "
188+ "execution model, not framework._" , "" ,
189+ "### By execution model" , "" ]
190+ if not scan_ok :
191+ rows += ["> source scan unavailable — suites counted as integration" , "" ]
192+ rows += ["| Category | Tests | Total time | Avg/test |" , "|---|---:|---:|---:|" ]
193+ for cat in MODEL_ORDER :
194+ if cat not in by_model :
195+ continue
196+ d = by_model [cat ]
197+ avg = d ["time" ] / d ["tests" ] if d ["tests" ] else 0
198+ rows .append (f"| { cat } | { d ['tests' ]} | { d ['time' ]:.1f} s | { avg :.3f} s |" )
199+
200+ rows += ["" , "### By API version" , "" ,
201+ "| Category | Tests | Total time | Avg/test |" , "|---|---:|---:|---:|" ]
202+ for cat in VERSION_ORDER :
203+ if cat not in by_version :
138204 continue
139- d = stats [cat ]
205+ d = by_version [cat ]
140206 avg = d ["time" ] / d ["tests" ] if d ["tests" ] else 0
141207 rows .append (f"| { cat } | { d ['tests' ]} | { d ['time' ]:.1f} s | { avg :.3f} s |" )
142208
143- # Highlight ratio
144- u = stats .get ("http4s v7 — unit/pure" )
145- lift_times = [stats [c ]["time" ] for c in CATEGORY_ORDER if c .startswith ("Lift" ) and c in stats ]
146- lift_tests = [stats [c ]["tests" ] for c in CATEGORY_ORDER if c .startswith ("Lift" ) and c in stats ]
147- if u and lift_tests :
148- lift_avg = sum (lift_times ) / sum (lift_tests )
149- unit_avg = u ["time" ] / u ["tests" ]
150- rows += [
151- "" ,
152- f"> **Unit/pure tests are { lift_avg / unit_avg :.0f} × faster than Lift integration tests** "
153- f"({ unit_avg :.3f} s vs { lift_avg :.3f} s per test)." ,
154- ]
209+ u = by_model .get ("unit/pure" )
210+ i = by_model .get ("integration" )
211+ if u and i and u ["tests" ] and i ["tests" ]:
212+ ua = u ["time" ] / u ["tests" ]
213+ ia = i ["time" ] / i ["tests" ]
214+ if ua > 0 :
215+ rows += ["" ,
216+ f"> **Unit/pure tests are { ia / ua :.0f} x faster per test than "
217+ f"integration tests** ({ ua :.3f} s vs { ia :.3f} s). This is the "
218+ f"migration win: logic that used to need a running server is "
219+ f"now pure unit-tested." ]
155220 return "\n " .join (rows )
156221
157222
@@ -164,16 +229,19 @@ def render_markdown(stats: dict) -> str:
164229 print (f"Usage: { sys .argv [0 ]} <reports-root-dir>" , file = sys .stderr )
165230 sys .exit (1 )
166231
167- stats = collect (sys .argv [1 ])
168- if not stats :
232+ src_root = os .environ .get ("OBP_TEST_SRC_ROOT" , DEFAULT_SRC_ROOT )
233+ fqmap , scan_ok = build_integration_map (src_root )
234+
235+ by_model , by_version = collect (sys .argv [1 ], fqmap )
236+ if not by_model and not by_version :
169237 print ("No matching surefire XML reports found." , file = sys .stderr )
170238 sys .exit (0 )
171239
172- print (render_plain (stats ))
240+ print (render_plain (by_model , by_version , scan_ok ))
173241
174242 summary_path = os .environ .get ("GITHUB_STEP_SUMMARY" )
175243 if summary_path :
176244 with open (summary_path , "a" ) as f :
177245 f .write ("\n " )
178- f .write (render_markdown (stats ))
246+ f .write (render_markdown (by_model , by_version , scan_ok ))
179247 f .write ("\n " )
0 commit comments