diff --git a/.github/scripts/run_gui_tests.py b/.github/scripts/run_gui_tests.py new file mode 100644 index 0000000000..d50f80f141 --- /dev/null +++ b/.github/scripts/run_gui_tests.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-or-later +""" +run_gui_tests.py + +List registered tests via `FreeCAD -t`, filter for GUI tests (names containing 'Gui'), and run each +GUI test module using the specified FreeCAD executable. + +Usage: + run_gui_tests.py [FREECAD_EXEC] + +If FREECAD_EXEC is omitted the script falls back to 'FreeCAD' on PATH. +If FREECAD_EXEC is a directory containing bin/FreeCAD, that binary is used. +If FREECAD_EXEC is an executable path, it is used directly. + +This script returns 0 if all GUI modules run successfully. Otherwise it returns the last non-zero +exit code. +""" +from __future__ import annotations +import sys +import subprocess +import os +from pathlib import Path + + +def find_executable(arg: str | None) -> str: + """Return the FreeCAD executable path to use. + + If `arg` is None or empty, returns the plain name 'FreeCAD' which will be looked up on PATH. If + `arg` is a directory and contains `bin/FreeCAD` that binary will be returned. If `arg` is a file + path it is returned as-is. Otherwise the original argument is returned. + + Common use cases: use the FreeCAD binary from a build directory or an installed FreeCAD prefix. + """ + if not arg: + return "FreeCAD" + p = Path(arg) + if p.is_dir(): + candidate = p / "bin" / "FreeCAD" + if candidate.exists(): + return str(candidate) + if p.is_file(): + return str(p) + # fallback: return as-is (may be on PATH) + return arg + + +def validate_executable(path: str) -> tuple[bool, str]: + """Return (ok, message). Checks if the executable exists or is likely on PATH. + + This is best effort: if a bare name is given (e.g. 'FreeCAD') we can't stat it here, so we + accept it but warn. If the path points to a file, we check executability. Also warn if the name + looks like the CLI-only 'FreeCADCmd'. + """ + p = Path(path) + if p.is_file(): + if not os.access(str(p), os.X_OK): + return False, f"File exists but is not executable: {path}" + if p.name.endswith("FreeCADCmd"): + return ( + True, + ( + "Warning: executable looks like 'FreeCADCmd' (CLI); GUI tests require the GUI " + "binary 'FreeCAD'." + ), + ) + return True, "" + # Bare name or non-existent path: accept but warn + if p.name.endswith("FreeCADCmd"): + return ( + True, + ( + "Warning: executable name looks like 'FreeCADCmd' (CLI); GUI tests require the GUI " + "binary 'FreeCAD'." + ), + ) + return True, "" + + +def run_and_capture(cmd: list[str]) -> tuple[int, str]: + """Run `cmd` and return (returncode, combined stdout+stderr string). + + If the executable is not found a return code of 127 is returned and a small error string is + provided as output to aid diagnosis. + """ + try: + proc = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False + ) + return proc.returncode, proc.stdout + except FileNotFoundError: + return 127, f"Executable not found: {cmd[0]}\n" + + +def parse_registered_tests(output: str) -> list[str]: + """Parse output from `FreeCAD -t` and return a list of registered test unit names. + + The function looks for the section starting with the literal 'Registered test units:' and + then collects non-empty, stripped lines from that point onwards as test names. + """ + lines = output.splitlines() + tests: list[str] = [] + started = False + for ln in lines: + if not started: + if "Registered test units:" in ln: + started = True + continue + s = ln.strip() + if not s: + # allow blank lines but keep going + continue + tests.append(s) + return tests + + +def main(argv: list[str]) -> int: + """Entry point: run GUI test modules registered in the FreeCAD executable. + + Returns the last non-zero exit code from any GUI test module, or 0 on success. + """ + exec_arg = argv[1] if len(argv) > 1 else None + freecad_exec = find_executable(exec_arg) + + print(f"Using FreeCAD executable: {freecad_exec}") + + ok, msg = validate_executable(freecad_exec) + if msg: + print(msg, file=sys.stderr) + if not ok: + print(f"Aborting: invalid FreeCAD executable: {freecad_exec}", file=sys.stderr) + return 3 + + code, out = run_and_capture([freecad_exec, "-t"]) + if code != 0: + print( + f"Warning: listing tests returned exit code {code}; attempting to parse output anyway", + file=sys.stderr, + ) + + tests = parse_registered_tests(out) + if not tests: + print("No registered tests found; exiting with success.") + return 0 + + gui_tests = [t for t in tests if "Gui" in t] + if not gui_tests: + print("No GUI tests found in registered tests; nothing to run.") + return 0 + + print("Found GUI test modules:") + for t in gui_tests: + print(" ", t) + + last_rc = 0 + for mod in gui_tests: + print(f"\nRunning GUI tests for module: {mod}") + rc, out = run_and_capture([freecad_exec, "-t", mod]) + print(out) + if rc != 0: + print(f"Module {mod} exited with code {rc}", file=sys.stderr) + last_rc = rc + + return last_rc + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/sub_buildUbuntu.yml b/.github/workflows/sub_buildUbuntu.yml index ac7e52f685..f038e18407 100644 --- a/.github/workflows/sub_buildUbuntu.yml +++ b/.github/workflows/sub_buildUbuntu.yml @@ -173,7 +173,8 @@ jobs: uses: ./.github/workflows/actions/runPythonTests with: testDescription: "GUI tests on build dir" - testCommand: xvfb-run ${{ env.builddir }}/bin/FreeCAD -t 0 + # Use Python wrapper to run only GUI-registered tests with the built FreeCAD executable + testCommand: xvfb-run python3 ./.github/scripts/run_gui_tests.py "${{ env.builddir }}" logFile: ${{ env.logdir }}TestGUIBuild.log reportFile: ${{env.reportdir}}${{ env.reportfilename }} @@ -207,7 +208,8 @@ jobs: uses: ./.github/workflows/actions/runPythonTests with: testDescription: "GUI tests on install" - testCommand: xvfb-run FreeCAD -t 0 + # Use Python wrapper to run only GUI-registered tests using the installed FreeCAD + testCommand: xvfb-run python3 ./.github/scripts/run_gui_tests.py "FreeCAD" logFile: ${{ env.logdir }}TestGUIInstall.log reportFile: ${{env.reportdir}}${{ env.reportfilename }}