Skip to content

Commit c647f32

Browse files
committed
Add reusable C++ test runner and wire into CI
1 parent 43bf98c commit c647f32

File tree

2 files changed

+298
-0
lines changed

2 files changed

+298
-0
lines changed

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ jobs:
6464
run: |
6565
msbuild "src\WORR-kex.sln" /m /p:Configuration=${{ matrix.configuration }} /p:Platform=${{ matrix.platform }}
6666
67+
- name: Run C++ tests
68+
if: matrix.identifier == 'windows'
69+
shell: pwsh
70+
run: |
71+
python tools/ci/run_tests.py
72+
6773
- name: Stage Windows artifacts
6874
if: matrix.identifier == 'windows'
6975
shell: pwsh
@@ -120,6 +126,11 @@ cmd.extend(['-o', 'game.so'])
120126
subprocess.check_call(cmd)
121127
PY
122128

129+
- name: Run C++ tests
130+
if: matrix.identifier == 'ubuntu'
131+
run: |
132+
python3 tools/ci/run_tests.py
133+
123134
- name: Stage Linux artifacts
124135
if: matrix.identifier == 'ubuntu'
125136
run: |

tools/ci/run_tests.py

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Compile and run standalone C++ tests.
4+
5+
This script discovers C++ sources under ``tests/`` prefixed with ``test_`` and
6+
compiles each one as an executable using the platform toolchain. The resulting
7+
executables are executed and the aggregated results are written to
8+
``artifacts/test-results``.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
import platform
15+
import shutil
16+
import subprocess
17+
import sys
18+
import textwrap
19+
from dataclasses import dataclass
20+
from datetime import datetime, UTC
21+
from pathlib import Path
22+
from typing import Iterable, List, Sequence
23+
24+
REPO_ROOT = Path(__file__).resolve().parents[2]
25+
TEST_ROOT = REPO_ROOT / "tests"
26+
SRC_ROOT = REPO_ROOT / "src"
27+
INCLUDE_DIRS = [SRC_ROOT, SRC_ROOT / "fmt", SRC_ROOT / "json"]
28+
ARTIFACT_DIR = REPO_ROOT / "artifacts" / "test-results"
29+
LOG_FILE = ARTIFACT_DIR / "test-log.txt"
30+
JUNIT_FILE = ARTIFACT_DIR / "junit.xml"
31+
32+
33+
@dataclass
34+
class TestResult:
35+
name: str
36+
source: Path
37+
executable: Path
38+
compiled: bool
39+
compile_returncode: int
40+
run_returncode: int | None
41+
compile_stdout: str
42+
compile_stderr: str
43+
run_stdout: str | None
44+
run_stderr: str | None
45+
46+
@property
47+
def passed(self) -> bool:
48+
return self.compiled and (self.run_returncode == 0)
49+
50+
51+
class ToolchainError(RuntimeError):
52+
pass
53+
54+
55+
def find_tests() -> List[Path]:
56+
if not TEST_ROOT.exists():
57+
return []
58+
return sorted(TEST_ROOT.glob("test_*.cpp"))
59+
60+
61+
def detect_compiler() -> Sequence[str]:
62+
system = platform.system()
63+
if system == "Windows":
64+
compiler = shutil.which("cl")
65+
if not compiler:
66+
raise ToolchainError("MSVC 'cl' compiler not found in PATH")
67+
return [compiler]
68+
69+
for candidate in ("clang++", "g++"):
70+
compiler = shutil.which(candidate)
71+
if compiler:
72+
return [compiler]
73+
74+
raise ToolchainError("Unable to locate a C++ compiler (clang++ or g++) in PATH")
75+
76+
77+
def build_command(compiler_cmd: Sequence[str], source: Path, output: Path) -> List[str]:
78+
system = platform.system()
79+
include_dirs = [str(path) for path in INCLUDE_DIRS if path.exists()]
80+
if not include_dirs:
81+
include_dirs = [str(SRC_ROOT)]
82+
83+
if system == "Windows":
84+
# ``cl`` requires include flags prefixed with ``/I``.
85+
includes: list[str] = []
86+
seen: set[str] = set()
87+
for directory in include_dirs:
88+
flag = f"/I{directory}"
89+
if flag not in seen:
90+
includes.append(flag)
91+
seen.add(flag)
92+
return [
93+
*compiler_cmd,
94+
"/nologo",
95+
"/std:c++20",
96+
*includes,
97+
str(source),
98+
f"/Fe:{output}",
99+
]
100+
101+
include_flags: list[str] = []
102+
for directory in include_dirs:
103+
include_flags.extend(["-I", directory])
104+
105+
return [
106+
*compiler_cmd,
107+
"-std=c++20",
108+
*include_flags,
109+
str(source),
110+
"-o",
111+
str(output),
112+
]
113+
114+
115+
def run_test(compiler_cmd: Sequence[str], source: Path, build_dir: Path) -> TestResult:
116+
build_dir.mkdir(parents=True, exist_ok=True)
117+
exe_suffix = ".exe" if platform.system() == "Windows" else ""
118+
executable = build_dir / (source.stem + exe_suffix)
119+
120+
compile_proc = subprocess.run(
121+
build_command(compiler_cmd, source, executable),
122+
cwd=REPO_ROOT,
123+
capture_output=True,
124+
text=True,
125+
)
126+
compiled = compile_proc.returncode == 0 and executable.exists()
127+
128+
run_returncode: int | None = None
129+
run_stdout: str | None = None
130+
run_stderr: str | None = None
131+
132+
if compiled:
133+
run_proc = subprocess.run(
134+
[str(executable)],
135+
cwd=REPO_ROOT,
136+
capture_output=True,
137+
text=True,
138+
)
139+
run_returncode = run_proc.returncode
140+
run_stdout = run_proc.stdout
141+
run_stderr = run_proc.stderr
142+
143+
return TestResult(
144+
name=source.stem,
145+
source=source,
146+
executable=executable,
147+
compiled=compiled,
148+
compile_returncode=compile_proc.returncode,
149+
run_returncode=run_returncode,
150+
compile_stdout=compile_proc.stdout,
151+
compile_stderr=compile_proc.stderr,
152+
run_stdout=run_stdout,
153+
run_stderr=run_stderr,
154+
)
155+
156+
157+
def write_log(results: Iterable[TestResult]) -> None:
158+
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
159+
timestamp = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
160+
lines = [f"C++ Test Run - {timestamp}", ""]
161+
for result in results:
162+
status = "PASS" if result.passed else "FAIL"
163+
lines.append(f"[{status}] {result.name} ({result.source.relative_to(REPO_ROOT)})")
164+
lines.append(" Compile return code: %s" % result.compile_returncode)
165+
if result.compile_stdout:
166+
lines.append(" Compile stdout:\n" + textwrap.indent(result.compile_stdout.rstrip(), " "))
167+
if result.compile_stderr:
168+
lines.append(" Compile stderr:\n" + textwrap.indent(result.compile_stderr.rstrip(), " "))
169+
if result.compiled:
170+
lines.append(" Run return code: %s" % result.run_returncode)
171+
if result.run_stdout:
172+
lines.append(" Run stdout:\n" + textwrap.indent(result.run_stdout.rstrip(), " "))
173+
if result.run_stderr:
174+
lines.append(" Run stderr:\n" + textwrap.indent(result.run_stderr.rstrip(), " "))
175+
lines.append("")
176+
LOG_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
177+
178+
179+
def write_junit(results: Iterable[TestResult]) -> None:
180+
import xml.etree.ElementTree as ET
181+
182+
results = list(results)
183+
testsuite = ET.Element(
184+
"testsuite",
185+
attrib={
186+
"name": "cpp-tests",
187+
"tests": str(len(results)),
188+
"failures": str(sum(1 for r in results if not r.passed)),
189+
"errors": "0",
190+
},
191+
)
192+
193+
for result in results:
194+
testcase = ET.SubElement(
195+
testsuite,
196+
"testcase",
197+
attrib={"name": result.name, "classname": "cpp"},
198+
)
199+
if not result.passed:
200+
message_lines = [
201+
f"Compile return code: {result.compile_returncode}",
202+
]
203+
if result.compile_stdout:
204+
message_lines.append("Compile stdout:\n" + result.compile_stdout)
205+
if result.compile_stderr:
206+
message_lines.append("Compile stderr:\n" + result.compile_stderr)
207+
if result.compiled and result.run_stdout:
208+
message_lines.append("Run stdout:\n" + result.run_stdout)
209+
if result.compiled and result.run_stderr:
210+
message_lines.append("Run stderr:\n" + result.run_stderr)
211+
failure = ET.SubElement(
212+
testcase,
213+
"failure",
214+
attrib={"message": "; ".join(line.splitlines()[0] for line in message_lines if line)},
215+
)
216+
failure.text = "\n".join(message_lines)
217+
218+
tree = ET.ElementTree(testsuite)
219+
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
220+
tree.write(JUNIT_FILE, encoding="utf-8", xml_declaration=True)
221+
222+
223+
def write_summary(results: Iterable[TestResult]) -> None:
224+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
225+
if not summary_path:
226+
return
227+
results = list(results)
228+
passed = sum(1 for r in results if r.passed)
229+
failed = len(results) - passed
230+
lines = [
231+
"## C++ Test Summary",
232+
"",
233+
f"* Total: {len(results)}",
234+
f"* Passed: {passed}",
235+
f"* Failed: {failed}",
236+
"",
237+
"| Test | Status |",
238+
"| --- | --- |",
239+
]
240+
for result in results:
241+
status = "✅ Pass" if result.passed else "❌ Fail"
242+
lines.append(f"| `{result.name}` | {status} |")
243+
lines.append("")
244+
with open(summary_path, "a", encoding="utf-8") as handle:
245+
handle.write("\n".join(lines))
246+
247+
248+
def main() -> int:
249+
try:
250+
compiler_cmd = detect_compiler()
251+
except ToolchainError as exc:
252+
print(f"error: {exc}", file=sys.stderr)
253+
return 1
254+
255+
tests = find_tests()
256+
if not tests:
257+
print("No tests found.")
258+
return 0
259+
260+
build_dir = ARTIFACT_DIR / "build"
261+
results: List[TestResult] = []
262+
263+
for test_source in tests:
264+
print(f"Running {test_source.name}...")
265+
result = run_test(compiler_cmd, test_source, build_dir)
266+
status = "PASS" if result.passed else "FAIL"
267+
print(f" {status}")
268+
results.append(result)
269+
270+
write_log(results)
271+
try:
272+
write_junit(results)
273+
except Exception as exc: # pragma: no cover - best effort only
274+
print(f"warning: failed to write JUnit report: {exc}", file=sys.stderr)
275+
write_summary(results)
276+
277+
failures = sum(1 for result in results if not result.passed)
278+
if failures:
279+
print(f"{failures} test(s) failed. See {LOG_FILE.relative_to(REPO_ROOT)} for details.")
280+
else:
281+
print(f"All {len(results)} test(s) passed.")
282+
283+
return 0 if failures == 0 else 1
284+
285+
286+
if __name__ == "__main__":
287+
sys.exit(main())

0 commit comments

Comments
 (0)