From 7cc4ceffe1e43ad81582a2bbfcb51a6d3d990cf5 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Sat, 30 May 2026 12:57:35 +0200 Subject: [PATCH] ci: forward-port current pipeline (harness + timeout) to gramps61 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring maintenance/gramps61's .github/ up to feature/ci-cd-pipeline-upstream. The branch carried an early (2026-05-19) branch-neutral snapshot that ran tests with a bare 'python3 -m unittest' — no per-module timeout, no silent-skip detection, no GI bootstrap. The harness added later (run_addon_tests.py, 2026-05-29) only landed on the feature branch and was never forward-ported here, so a hanging addon test (e.g. TMGimporter's real-DB import on Windows) ran unbounded toward the 6h job default. Adds run_addon_tests.py (per-module subprocess timeout + honest skip accounting), addon_system_deps.py, gi_bootstrap/sitecustomize.py, and the matching ci.yml/environment.yml wiring. The ci.yml stays branch-neutral. Forward-port only; no addon changes. --- .github/environment.yml | 7 + .github/scripts/addon_system_deps.py | 209 +++++++++++++++++ .github/scripts/gi_bootstrap/sitecustomize.py | 29 +++ .github/scripts/run_addon_tests.py | 210 ++++++++++++++++++ .github/workflows/ci.yml | 118 +++++++++- 5 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 .github/scripts/addon_system_deps.py create mode 100644 .github/scripts/gi_bootstrap/sitecustomize.py create mode 100644 .github/scripts/run_addon_tests.py diff --git a/.github/environment.yml b/.github/environment.yml index dd9e85603..f1c7c9ae7 100644 --- a/.github/environment.yml +++ b/.github/environment.yml @@ -10,6 +10,13 @@ dependencies: # installed at CI runtime by ci.yml's auto-derive step from .gpr.py # requires_mod — single source of truth. Keep only the stable base # here (Gramps + orjson for plugin registration). + # + # conda-forge has no gramps 6.1 yet, so on a maintenance/gramps61 (or later) + # branch this resolves to 6.0.x — i.e. the Windows lane validates addons + # against conda-forge's newest in-range gramps, not the branch's exact series + # (the Linux CI image git-builds the exact series; conda-Windows cannot — see + # ci.yml's "Report gramps-vs-branch series" step). When 6.1 reaches + # conda-forge the pin picks it up automatically. - pip: - "gramps>=6.0,<6.1" - orjson diff --git a/.github/scripts/addon_system_deps.py b/.github/scripts/addon_system_deps.py new file mode 100644 index 000000000..6e3cef514 --- /dev/null +++ b/.github/scripts/addon_system_deps.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Single source of truth for addon *system* dependencies in CI. + +Addons declare three dependency kinds in their ``.gpr.py``: + +* ``requires_mod`` — importable Python modules. pip-installable; ci.yml already + auto-derives these from the ``.gpr.py`` files. Nothing to do here. +* ``requires_gi`` — GObject-introspection typelibs (e.g. ``GooCanvas``). +* ``requires_exe`` — system executables (e.g. ``dot`` from graphviz). + +The latter two are *system* packages: not pip-installable, named differently per +platform, and Gramps' own ``Requirements`` only *checks* them (never installs). +This module maps each declared ``requires_gi`` namespace / ``requires_exe`` name +to its package on each CI platform and scans the addons for what they declare, +so ci.yml derives the install list from one place instead of a hand-kept list. + +Platform availability is asymmetric and encoded here: the GTK 3 addon libs +(goocanvas, osm-gps-map, gexiv2) exist on Debian/apt but **not on conda-forge**, +so the conda (Windows) lane cannot install them — addons needing them skip there +by necessity. A ``conda`` value of ``None`` records that. + +Pure stdlib so it runs anywhere in CI without bootstrapping. + +CLI:: + + addon_system_deps.py --platform apt # space-separated install list + addon_system_deps.py --platform conda # (only packages available there) + addon_system_deps.py --unmapped . # declared deps with no map entry; exit 1 if any +""" + +# ------------------------ +# Python modules +# ------------------------ +from __future__ import annotations + +import argparse +import ast +import glob +import os +import re +import sys + +# --------------------------------------------------------------------------- +# The map. Keys are what addons declare; values give the package per platform. +# A None value means "no package provides this on that platform" (so it is not +# installed there and an addon needing it is expected to skip). +# --------------------------------------------------------------------------- + +# requires_gi namespace -> package providing the typelib, per platform. +GI_PACKAGES: dict[str, dict[str, str | None]] = { + "GExiv2": {"apt": "gir1.2-gexiv2-0.10", "conda": None}, + "GooCanvas": {"apt": "gir1.2-goocanvas-2.0", "conda": None}, + "OsmGpsMap": {"apt": "gir1.2-osmgpsmap-1.0", "conda": None}, + # PlaceCoordinateGramplet declares GeocodeGlib 1.0, but modern distros ship + # only the 2.0 typelib and conda-forge ships none; the addon has no tests. + # Recorded so the drift-guard recognises the namespace; not installed. + "GeocodeGlib": {"apt": None, "conda": None}, +} + +# requires_exe executable -> package providing it, per platform. +EXE_PACKAGES: dict[str, dict[str, str | None]] = { + "dot": {"apt": "graphviz", "conda": "graphviz"}, +} + +PLATFORMS = ("apt", "conda") + + +# ------------------------------------------------------------ +# +# scanning +# +# ------------------------------------------------------------ +_GI_RE = re.compile(r"requires_gi\s*=\s*(\[[^\]]*\])") +_EXE_RE = re.compile(r"requires_exe\s*=\s*(\[[^\]]*\])") + + +def _gpr_files(root: str) -> list[str]: + return sorted(glob.glob(os.path.join(root, "*", "*.gpr.py"))) + + +def _literal(src: str): + try: + return ast.literal_eval(src) + except (ValueError, SyntaxError): + return [] + + +def _scan(root: str, pattern: re.Pattern, first_of_tuple: bool) -> set[str]: + found: set[str] = set() + for path in _gpr_files(root): + try: + text = open(path, encoding="utf-8").read() + except OSError: + continue + for match in pattern.finditer(text): + for entry in _literal(match.group(1)): + if first_of_tuple and isinstance(entry, (tuple, list)): + entry = entry[0] if entry else None + if entry: + found.add(entry) + return found + + +def scan_gi_namespaces(root: str) -> set[str]: + return _scan(root, _GI_RE, first_of_tuple=True) + + +def scan_executables(root: str) -> set[str]: + return _scan(root, _EXE_RE, first_of_tuple=False) + + +def addon_requirements(addon_dir: str) -> tuple[set[str], set[str]]: + """Return (gi_namespaces, executables) declared by a single addon dir.""" + gi: set[str] = set() + exe: set[str] = set() + for path in sorted(glob.glob(os.path.join(addon_dir, "*.gpr.py"))): + try: + text = open(path, encoding="utf-8").read() + except OSError: + continue + for match in _GI_RE.finditer(text): + for entry in _literal(match.group(1)): + ns = entry[0] if isinstance(entry, (tuple, list)) else entry + if ns: + gi.add(ns) + for match in _EXE_RE.finditer(text): + for entry in _literal(match.group(1)): + if entry: + exe.add(entry) + return gi, exe + + +# ------------------------------------------------------------ +# +# derivation +# +# ------------------------------------------------------------ +def packages(platform: str) -> list[str]: + """All install-by-name packages available for a platform (full mapped set).""" + pkgs: list[str] = [] + for table in (GI_PACKAGES, EXE_PACKAGES): + for entry in table.values(): + pkg = entry.get(platform) + if pkg: + pkgs.append(pkg) + return sorted(set(pkgs)) + + +def unmapped(root: str) -> tuple[set[str], set[str]]: + """Declared deps with no entry in the maps at all (drift).""" + return ( + scan_gi_namespaces(root) - set(GI_PACKAGES), + scan_executables(root) - set(EXE_PACKAGES), + ) + + +def addon_satisfiable_on(addon_dir: str, platform: str) -> bool: + """ + True if every system dep the addon declares has a package on this platform. + + Used by the test runner to tell an *expected* platform skip (a declared dep + that simply is not packaged here, e.g. goocanvas on conda) from a suspicious + all-skip that should fail. + """ + gi, exe = addon_requirements(addon_dir) + for ns in gi: + entry = GI_PACKAGES.get(ns) + if entry is None or entry.get(platform) is None: + return False + for name in exe: + entry = EXE_PACKAGES.get(name) + if entry is None or entry.get(platform) is None: + return False + return True + + +# ------------------------------------------------------------ +# +# CLI +# +# ------------------------------------------------------------ +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", choices=PLATFORMS) + parser.add_argument( + "--unmapped", + metavar="ROOT", + help="print declared GI/exe deps with no map entry; exit 1 if any", + ) + args = parser.parse_args(argv) + + if args.unmapped is not None: + gi, exe = unmapped(args.unmapped) + for ns in sorted(gi): + print(f"gi:{ns}") + for name in sorted(exe): + print(f"exe:{name}") + return 1 if (gi or exe) else 0 + + if args.platform: + print(" ".join(packages(args.platform))) + return 0 + + parser.error("nothing to do: pass --platform or --unmapped") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/gi_bootstrap/sitecustomize.py b/.github/scripts/gi_bootstrap/sitecustomize.py new file mode 100644 index 000000000..9f5cb090d --- /dev/null +++ b/.github/scripts/gi_bootstrap/sitecustomize.py @@ -0,0 +1,29 @@ +"""Pin the GObject-introspection versions, like the Gramps GUI launcher. + +Put this directory on ``PYTHONPATH`` for a test step and the interpreter imports +this ``sitecustomize`` at startup — before any test (or subprocess it spawns) +imports a ``gramps.gui`` module. + +Why: gramps pins its GI versions in the GUI launcher (``gramps/gui/grampsgui.py`` +calls ``gi.require_version`` for Pango/PangoCairo/Gtk at import). A test that +imports a ``gramps.gui.*`` module directly never runs that launcher, so Gtk/Pango +get imported with no version pinned first — emitting a ``PyGIWarning`` and, on a +host where GTK 4 is the default, risking the wrong stack. This shim performs the +same bootstrap, so tests run under the supported GTK 3 stack. + +Used for the discover-based / subprocess-loading steps (e.g. plugin +registration), where the bootstrap must be inherited via ``PYTHONPATH`` by every +spawned interpreter. The in-process unit/integration runner +(``run_addon_tests.py``) does the same ``require_version`` itself. +""" + +try: + import gi + + for _ns, _ver in (("Pango", "1.0"), ("PangoCairo", "1.0"), ("Gtk", "3.0")): + try: + gi.require_version(_ns, _ver) + except (ValueError, AttributeError): + pass +except ImportError: + pass diff --git a/.github/scripts/run_addon_tests.py b/.github/scripts/run_addon_tests.py new file mode 100644 index 000000000..6491aa0e5 --- /dev/null +++ b/.github/scripts/run_addon_tests.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Run per-addon unit tests with a GI bootstrap, a timeout, and honest skips. + +Replaces a bare ``python -m unittest `` in CI. It does three things +plain unittest does not: + +1. **GI version bootstrap.** Before any test imports a ``gramps.gui`` module, it + calls ``gi.require_version`` for Pango/PangoCairo/Gtk — the set the Gramps + GUI launcher (``gramps/gui/grampsgui.py``) pins at startup. A direct test + import never runs that launcher, so without this the first + ``from gi.repository import Gtk`` (in gramps core) warns and risks the wrong + GTK on a host where GTK 4 is the default. + +2. **A per-module timeout.** Each module runs in its own subprocess with a wall + clock. A test that hangs (e.g. a DB import that blocks on a platform) would + otherwise hang the whole CI job indefinitely — neither plain unittest nor + xmlrunner has a timeout. A module that exceeds the limit is killed and + reported as a FAILURE, so the job stays bounded and names the culprit. + +3. **Honest skip accounting.** unittest exits 0 when every test SKIPS, so a + wholly-skipped module reads as a pass. This runner FAILS such a module — + UNLESS the addon's declared system deps are unavailable on this platform + (e.g. goocanvas/osm-gps-map are not on conda-forge), in which case the skip + is expected and tolerated (the map lives in ``addon_system_deps.py``). + +Usage:: + + run_addon_tests.py --platform apt Addon.tests.test_x Other.tests.test_y + run_addon_tests.py --platform conda Addon.tests.test_x + +Exit code is non-zero if any module is a hard failure (test failure/error, +timeout, or an unexpected all-skip on a platform where the addon's deps are +available). +""" + +# ------------------------ +# Python modules +# ------------------------ +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import addon_system_deps as deps # noqa: E402 + +# Per-module wall clock. Generous enough for a legitimate DB-backed suite, small +# enough that a hung test is caught promptly instead of running to the job cap. +# Overridable via env for tuning/testing. +MODULE_TIMEOUT_S = int(os.environ.get("RUN_ADDON_TESTS_TIMEOUT", "300")) + +# Markers the worker prints so the parent can read the outcome without needing +# xmlrunner (820's env has only stdlib unittest). +_OK = "__RESULT__ ok" +_LOADERROR = "__RESULT__ loaderror" + + +def _bootstrap_gi() -> None: + """Pin the GI versions the Gramps GUI launcher pins, before tests import.""" + try: + import gi + except ImportError: + return + for namespace, version in (("Pango", "1.0"), ("PangoCairo", "1.0"), ("Gtk", "3.0")): + try: + gi.require_version(namespace, version) + except (ValueError, AttributeError): + pass + + +# ------------------------------------------------------------ +# +# worker: runs ONE module in this (sub)process +# +# ------------------------------------------------------------ +def _run_worker(modname: str) -> int: + """Run a single module; print a machine-readable result line; exit 0. + + The parent classifies pass/fail from the printed counts and its own platform + knowledge, so the worker always exits 0 (a non-zero exit would be + indistinguishable from an interpreter crash). + """ + _bootstrap_gi() + try: + suite = unittest.defaultTestLoader.loadTestsFromName(modname) + except Exception as exc: # import-time failure + print(f"{_LOADERROR} {exc!r}", flush=True) + return 0 + result = unittest.TextTestRunner(verbosity=2).run(suite) + broke = len(result.failures) + len(result.errors) + print( + f"{_OK} tests={result.testsRun} skipped={len(result.skipped)} broke={broke}", + flush=True, + ) + return 0 + + +# ------------------------------------------------------------ +# +# parent: spawns a timed worker per module and classifies the outcome +# +# ------------------------------------------------------------ +def _classify(modname: str, platform: str, root: str) -> tuple[bool, str]: + """Run one module in a timed subprocess. Return (is_hard_failure, summary).""" + addon = modname.split(".", 1)[0] + satisfiable = deps.addon_satisfiable_on(os.path.join(root, addon), platform) + + proc = subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "--worker", modname], + stdout=subprocess.PIPE, + stderr=None, # stream the test output straight to the CI log + text=True, + ) + try: + # communicate() enforces the wall clock and reaps the process. + stdout, _ = proc.communicate(timeout=MODULE_TIMEOUT_S) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + return True, f" FAIL {modname} — timed out after {MODULE_TIMEOUT_S}s (hung)" + + out_lines = stdout.splitlines() + for line in out_lines: # echo the worker's result marker into the log + print(line) + + result_line = next( + (ln for ln in reversed(out_lines) if ln.startswith("__RESULT__")), "" + ) + + if result_line.startswith(_LOADERROR): + if satisfiable: + return True, f" FAIL {modname} — load error" + return False, ( + f" skip {modname} — not loadable on {platform} " + f"(addon system deps unavailable here)" + ) + + if not result_line.startswith(_OK): + return True, f" FAIL {modname} — no result (worker crashed)" + + fields = dict(tok.split("=", 1) for tok in result_line.split()[2:] if "=" in tok) + ran = int(fields.get("tests", 0)) + skipped = int(fields.get("skipped", 0)) + broke = int(fields.get("broke", 0)) + + if broke: + return True, f" FAIL {modname} — {broke} failed/errored" + if ran > 0 and skipped == ran: + if satisfiable: + return True, ( + f" FAIL {modname} — all {ran} tests skipped " + f"(degraded coverage; deps ARE available on {platform})" + ) + return False, ( + f" skip {modname} — all {ran} skipped, expected " + f"(addon system deps unavailable on {platform})" + ) + if skipped: + return False, f" ok {modname} — {ran} tests, {skipped} skipped" + return False, f" ok {modname} — {ran} tests" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", choices=deps.PLATFORMS) + parser.add_argument( + "--root", + default=".", + help="addons-source root holding the / dirs (default: cwd)", + ) + parser.add_argument( + "--worker", + metavar="MODULE", + help="internal: run this single module and print its result line", + ) + parser.add_argument("modules", nargs="*", help="dotted test modules to run") + args = parser.parse_args(argv) + + if args.worker: + return _run_worker(args.worker) + + if not args.platform: + parser.error("--platform is required in parent mode") + if not args.modules: + print("No per-addon unit test modules found") + return 0 + + hard_failures: list[str] = [] + summary: list[str] = [] + for modname in args.modules: + failed, line = _classify(modname, args.platform, args.root) + summary.append(line) + if failed: + hard_failures.append(modname) + + print("\n=== addon test summary ===") + for line in summary: + print(line) + + if hard_failures: + print(f"\n{len(hard_failures)} module(s) failed: {hard_failures}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ad018e0d..d606be7b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,6 +198,35 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install addon system deps (derived from requires_gi / requires_exe) + # System deps (GI typelibs, executables) are not pip-installable and + # gramps only *checks* them, so the image cannot bake them generically + # (its build context excludes addons-source). Derive the apt set from + # every .gpr.py via the single-source map and install it — the container + # runs as root. Mirrors the requires_mod derivation below. + shell: bash + run: | + pkgs=$(python3 .github/scripts/addon_system_deps.py --platform apt) + if [ -n "$pkgs" ]; then + echo "→ addon system deps (apt): $pkgs" + apt-get update + apt-get install -y --no-install-recommends $pkgs + else + echo "no requires_gi / requires_exe declarations found" + fi + + - name: Validate addon system deps are mapped + # Every requires_gi / requires_exe an addon declares must have an entry + # in addon_system_deps.py, so the install list never silently drifts + # from what addons declare (the system-dep analogue of the requires_mod + # find_spec gate below). + shell: bash + run: | + python3 .github/scripts/addon_system_deps.py --unmapped . || { + echo "::error::Addon(s) declare requires_gi/requires_exe with no entry in .github/scripts/addon_system_deps.py — add a mapping row." + exit 1 + } + - name: Install addon runtime deps (derived from requires_mod) # Auto-derive the union of requires_mod across every .gpr.py in # the repo. Mirrors Gramps' Addon Manager install path @@ -332,7 +361,14 @@ jobs: done if [ -n "$modules" ]; then echo "Running unit tests:$modules" - python3 -m unittest -v $modules + # xvfb-run: some addons create a Gtk style context at import and + # need a display (else a hard Gtk-ERROR abort, not a clean skip). + # run_addon_tests.py: pins the GI versions like gramps' launcher + # (so gramps.gui imports load GTK 3, no PyGIWarning) and fails a + # wholly-skipped module unless the addon's deps are unavailable on + # this platform. + xvfb-run -a --server-args="-screen 0 1920x1080x24" \ + python3 .github/scripts/run_addon_tests.py --platform apt --root . $modules else echo "No per-addon unit test modules found" fi @@ -342,6 +378,7 @@ jobs: # ----------------------------------------------------------------- unit-test-windows: name: Unit Tests (Windows) + needs: setup runs-on: windows-latest defaults: run: @@ -363,6 +400,47 @@ jobs: mamba list | head -30 python -c "import gramps, gi; print('deps OK')" + - name: Report gramps-vs-branch series (Windows lane caveat) + # The Linux lane git-builds the branch's exact gramps in its CI image + # (.github/docker/gramps-ci/Dockerfile, PyPI-first/git-fallback). The + # conda-forge Windows lane CANNOT match that: conda-forge has no gramps + # 6.1 yet, and gramps' own Windows build targets MSYS2 UCRT64, not conda + # — building 6.1 from git here fails in gramps' build hook (build_intl's + # `msgfmt --xml` cannot locate the shared-mime-info/appstream ITS rules, + # which are absent in the conda env). So on a maintenance/gramps61 (or + # later) branch this lane validates addons against conda-forge's newest + # in-range gramps (6.0.x today) rather than the branch's series. This + # step surfaces that honestly; it does NOT fail. Addon tests that depend + # on series-exact gramps behaviour skip themselves on Windows (e.g. + # TMGimporter's real-DB import tests) and run on the Linux lane instead. + # When gramps 6.1 reaches conda-forge, environment.yml's pin picks it up + # and this caveat disappears on its own. + run: | + suffix="${{ needs.setup.outputs.branch_suffix }}" # e.g. gramps61 + digits="${suffix#gramps}" # e.g. 61 + want="${digits:0:1}.${digits:1}" # e.g. 6.1 + have="$(python -c 'from gramps.version import major_version; print(major_version)')" + if [ "$want" = "$have" ]; then + echo "conda-forge gramps $have matches branch series $want — addons tested against the branch's gramps" + else + echo "::warning::Windows lane: branch targets gramps $want but conda-forge ships $have; addons here are validated against $have. Full $want coverage is on the Linux lane (its CI image git-builds $want). See step comment for why conda-Windows cannot build $want." + fi + + - name: Install addon system deps (derived, conda-available subset) + # Only the conda-forge-available subset (e.g. graphviz). The GTK 3 addon + # GI libs (goocanvas/osm-gps-map/gexiv2) are NOT on conda-forge, so the + # map returns None for them on conda and they are not installed; addons + # needing them skip on Windows by necessity, which run_addon_tests + # tolerates (--platform conda). + run: | + pkgs=$(python .github/scripts/addon_system_deps.py --platform conda) + if [ -n "$pkgs" ]; then + echo "→ addon system deps (conda): $pkgs" + mamba install -y -c conda-forge $pkgs + else + echo "no conda-available addon system deps to install" + fi + - name: Install addon runtime deps (derived from requires_mod) # See unit-test-linux for rationale. Uses `python` (conda-forge # env) to match the surrounding Windows job style. @@ -471,7 +549,10 @@ jobs: done if [ -n "$modules" ]; then echo "Running unit tests:$modules" - python -m unittest -v $modules + # No xvfb on Windows (GTK renders natively). run_addon_tests pins + # the GI versions and tolerates addons whose GI deps are not on + # conda-forge (they skip here by platform necessity). + python .github/scripts/run_addon_tests.py --platform conda --root . $modules else echo "No per-addon unit test modules found" fi @@ -489,6 +570,21 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install addon system deps (derived from requires_gi / requires_exe) + # Same as unit-test-linux: derive the apt set from the single-source + # map and install it (container runs as root). The plugin registration + # test subprocess-loads each addon module, so the GI typelibs those + # modules import must be present here too. (Mapping is drift-guarded in + # unit-test-linux, which this job needs:, so no duplicate gate here.) + shell: bash + run: | + pkgs=$(python3 .github/scripts/addon_system_deps.py --platform apt) + if [ -n "$pkgs" ]; then + echo "→ addon system deps (apt): $pkgs" + apt-get update + apt-get install -y --no-install-recommends $pkgs + fi + - name: Install addon runtime deps (derived from requires_mod) # See unit-test-linux for rationale. The plugin registration test # subprocess-loads each addon's module, which imports its @@ -570,10 +666,21 @@ jobs: # shell: bash for consistency with the surrounding steps; the # current command uses no bashisms, but keeps this block safe # against future edits. Container default is /bin/sh → dash. + # + # gi_bootstrap on PYTHONPATH pins the GI versions (like the gramps GUI + # launcher) for this process AND the addon-module subprocesses this test + # spawns, so gramps.gui imports load GTK 3 without a PyGIWarning. + # + # NOT run under xvfb: this test only *loads* (imports) addon modules in + # subprocesses and tolerates load failures; it does not render. Giving it + # a display made an addon load hang on the (absent) AT-SPI accessibility + # bus until the per-load timeout. Imports that build a Gtk style context + # are exercised under xvfb in the unit/integration test runs instead. shell: bash env: - PYTHONPATH: . - run: python3 -m unittest discover -s tests -p "test_*.py" -t . -v + PYTHONPATH: .github/scripts/gi_bootstrap:. + run: | + python3 -m unittest discover -s tests -p "test_*.py" -t . -v - name: Run per-addon integration tests # shell: bash — see unit-test-linux for rationale; the @@ -603,7 +710,8 @@ jobs: done if [ -n "$modules" ]; then echo "Running per-addon integration tests:$modules" - python3 -m unittest -v $modules + xvfb-run -a --server-args="-screen 0 1920x1080x24" \ + python3 .github/scripts/run_addon_tests.py --platform apt --root . $modules else echo "No per-addon integration test modules found" fi