sTodo-m5paper-client/libraries/FastLED/ci/cpp_test_run.py
2025-06-30 20:47:33 +02:00

269 lines
8.8 KiB
Python

import argparse
import os
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from ci.paths import PROJECT_ROOT
@dataclass
class FailedTest:
name: str
return_code: int
stdout: str
def run_command(command, use_gdb=False) -> tuple[int, str]:
captured_lines = []
if use_gdb:
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as gdb_script:
gdb_script.write("set pagination off\n")
gdb_script.write("run\n")
gdb_script.write("bt full\n")
gdb_script.write("info registers\n")
gdb_script.write("x/16i $pc\n")
gdb_script.write("thread apply all bt full\n")
gdb_script.write("quit\n")
gdb_command = (
f"gdb -return-child-result -batch -x {gdb_script.name} --args {command}"
)
process = subprocess.Popen(
gdb_command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # Merge stderr into stdout
shell=True,
text=True,
bufsize=1, # Line buffered
)
assert process.stdout is not None
# Stream and capture output
while True:
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
captured_lines.append(line.rstrip())
print(line, end="") # Print in real-time
os.unlink(gdb_script.name)
output = "\n".join(captured_lines)
return process.returncode, output
else:
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # Merge stderr into stdout
shell=True,
text=True,
bufsize=1, # Line buffered
)
assert process.stdout is not None
# Stream and capture output
while True:
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
captured_lines.append(line.rstrip())
print(line, end="") # Print in real-time
output = "\n".join(captured_lines)
return process.returncode, output
def compile_tests(clean: bool = False, unknown_args: list[str] = []) -> None:
os.chdir(str(PROJECT_ROOT))
print("Compiling tests...")
command = ["uv", "run", "ci/cpp_test_compile.py"]
if clean:
command.append("--clean")
command.extend(unknown_args)
return_code, _ = run_command(" ".join(command))
if return_code != 0:
print("Compilation failed:")
sys.exit(1)
print("Compilation successful.")
def run_tests(specific_test: str | None = None) -> None:
test_dir = os.path.join("tests", ".build", "bin")
if not os.path.exists(test_dir):
print(f"Test directory not found: {test_dir}")
sys.exit(1)
print("Running tests...")
failed_tests: list[FailedTest] = []
files = os.listdir(test_dir)
# filter out all pdb files (windows) and only keep test_ executables
files = [f for f in files if not f.endswith(".pdb") and f.startswith("test_")]
# If specific test is specified, filter for just that test
if specific_test:
test_name = f"test_{specific_test}"
if sys.platform == "win32":
test_name += ".exe"
files = [f for f in files if f == test_name]
if not files:
print(f"Test {test_name} not found in {test_dir}")
sys.exit(1)
for test_file in files:
test_path = os.path.join(test_dir, test_file)
if os.path.isfile(test_path) and os.access(test_path, os.X_OK):
print(f"Running test: {test_file}")
return_code, stdout = run_command(test_path)
output = stdout
failure_pattern = re.compile(r"Test .+ failed with return code (\d+)")
failure_match = failure_pattern.search(output)
is_crash = failure_match is not None
if is_crash:
print("Test crashed. Re-running with GDB to get stack trace...")
_, gdb_stdout = run_command(test_path, use_gdb=True)
stdout += "\n--- GDB Output ---\n" + gdb_stdout
# Extract crash information
crash_info = extract_crash_info(gdb_stdout)
print(f"Crash occurred at: {crash_info.file}:{crash_info.line}")
print(f"Cause: {crash_info.cause}")
print(f"Stack: {crash_info.stack}")
print("Test output:")
print(stdout)
if return_code == 0:
print("Test passed")
elif is_crash:
if failure_match:
print(f"Test crashed with return code {failure_match.group(1)}")
else:
print(f"Test crashed with return code {return_code}")
else:
print(f"Test failed with return code {return_code}")
print("-" * 40)
if return_code != 0:
failed_tests.append(FailedTest(test_file, return_code, stdout))
if failed_tests:
for failed_test in failed_tests:
print(
f"Test {failed_test.name} failed with return code {failed_test.return_code}\n{failed_test.stdout}"
)
tests_failed = len(failed_tests)
failed_test_names = [test.name for test in failed_tests]
print(
f"{tests_failed} test{'s' if tests_failed != 1 else ''} failed: {', '.join(failed_test_names)}"
)
sys.exit(1)
print("All tests passed.")
@dataclass
class CrashInfo:
cause: str = "Unknown"
stack: str = "Unknown"
file: str = "Unknown"
line: str = "Unknown"
def extract_crash_info(gdb_output: str) -> CrashInfo:
lines = gdb_output.split("\n")
crash_info = CrashInfo()
try:
for i, line in enumerate(lines):
if line.startswith("Program received signal"):
try:
crash_info.cause = line.split(":", 1)[1].strip()
except IndexError:
crash_info.cause = line.strip()
elif line.startswith("#0"):
crash_info.stack = line
for j in range(i, len(lines)):
if "at" in lines[j]:
try:
_, location = lines[j].split("at", 1)
location = location.strip()
if ":" in location:
crash_info.file, crash_info.line = location.rsplit(
":", 1
)
else:
crash_info.file = location
except ValueError:
pass # If split fails, we keep the default values
break
break
except Exception as e:
print(f"Error parsing GDB output: {e}")
return crash_info
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Compile and run C++ tests")
parser.add_argument(
"--compile-only",
action="store_true",
help="Only compile the tests without running them",
)
parser.add_argument(
"--run-only",
action="store_true",
help="Only run the tests without compiling them",
)
parser.add_argument(
"--only-run-failed-test",
action="store_true",
help="Only run the tests that failed in the previous run",
)
parser.add_argument(
"--clean", action="store_true", help="Clean build before compiling"
)
parser.add_argument(
"--test",
help="Specific test to run (without test_ prefix)",
)
parser.add_argument(
"--clang",
help="Use Clang compiler",
action="store_true",
)
args, unknown = parser.parse_known_args()
args.unknown = unknown
return args
def main() -> None:
args = parse_args()
run_only = args.run_only
compile_only = args.compile_only
specific_test = args.test
only_run_failed_test = args.only_run_failed_test
use_clang = args.clang
if not run_only:
passthrough_args = args.unknown
if use_clang:
passthrough_args.append("--use-clang")
compile_tests(clean=args.clean, unknown_args=passthrough_args)
if not compile_only:
if specific_test:
run_tests(specific_test)
else:
cmd = "ctest --test-dir tests/.build --output-on-failure"
if only_run_failed_test:
cmd += " --rerun-failed"
rtn, stdout = run_command(cmd)
if rtn != 0:
print("Failed tests:")
print(stdout)
sys.exit(1)
if __name__ == "__main__":
main()