Skip to content

Commit 6b83be2

Browse files
eduralphclaude
andcommitted
test: harden the addon test suite (adversarial-review follow-ups)
Follow-ups from repeated adversarial review of the suite: - Load test: display/GTK/environment failures are classified consistently (anchored signatures, unit-tested) and advisory by default; a genuine hard failure — or an import hang, detected via a REGISTRY_READY marker rather than a slow registry scan — always gates. One slow addon load can no longer abort the whole test. - Strict mode (GRAMPS_ADDON_TEST_STRICT=1) turns the smoke run into a full gate: dependency skips, environment failures and unverified addon dependencies become hard failures; only slow registry-scan timeouts stay advisory. Covered by synthetic regression tests that don't boot the registry. - Import/export smoke tests fail (not silently skip) when a dependency-satisfied plugin fails to load. - Unlisted-addon manifest: source-based and per-registration (parses each .gpr.py with ast), so an accidental include_in_listing=False — even on one plugin inside a mixed directory like TMGimporter — fails the test. - Neutralise blocking modal dialogs an addon may pop at import (e.g. lxml without python3-lxml) so a load can't hang or scatter UI. - Silence expected locale / PyGI noise so results are readable. - Failure messages lead with the real failures; make.py's test docstring and the load-test docstring document default (smoke) vs strict (full gate) modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bdd1a0c commit 6b83be2

6 files changed

Lines changed: 624 additions & 68 deletions

File tree

make.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@
5454
Runs the repo-root `tests/` suite (unittest discovery for
5555
`test_*.py`). GRAMPSPATH must point at a Gramps checkout so the
5656
addon plugins are importable. Exits non-zero on any failure.
57+
58+
Default mode is a headless developer SMOKE run, not a full gate:
59+
- only listed addons (include_in_listing=True) are load/version/
60+
dependency checked; unlisted ones are covered only by the
61+
listing-manifest test;
62+
- failures that are really about the environment — no display, a
63+
missing GI/typelib, a native GTK abort — are advisory, not fatal;
64+
- tests needing extra tools skip (e.g. i18n extraction without
65+
`xgettext`, GUI loads without a display).
66+
For a full gate, run under a complete runtime (Xvfb + gettext + all
67+
GI typelibs) with GRAMPS_ADDON_TEST_STRICT=1: unmet dependencies,
68+
display/GTK failures and unverified addon dependencies then become
69+
hard failures. (Slow registry-scan timeouts stay advisory — they are
70+
a performance signal, not a correctness one; a genuine import hang is
71+
always a hard failure.)
5772
"""
5873
import shutil
5974
import glob
@@ -1079,6 +1094,12 @@ def register(ptype, **kwargs):
10791094
extract_po(addon)
10801095

10811096
elif command == "test":
1097+
import logging
1098+
1099+
# Quiet the "no compiled locale" warning Gramps logs at import: this
1100+
# command imports Gramps (via check_gramps_path) before the test package's
1101+
# own suppression runs, and translations are irrelevant to the tests.
1102+
logging.getLogger(".gramps.gen.utils.grampslocale").setLevel(logging.ERROR)
10821103
check_gramps_path(command)
10831104
import unittest
10841105

tests/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@
66
``gramps.gui.*`` module directly never runs the launcher's own
77
``require_version``; without this the GI stack can resolve to GTK 4 on a host
88
where that is the default — emitting ``PyGIWarning`` and risking the wrong stack.
9+
10+
This module also runs before Gramps is imported, so it is where we silence
11+
log/warning spam that is expected when testing *uncompiled, source-tree* addons
12+
(no ``locale/*.mo``) and would otherwise bury the test results.
913
"""
1014

15+
import logging
16+
import warnings
17+
1118
try:
1219
import gi
1320

@@ -17,3 +24,17 @@
1724
# No PyGObject, or the 3.0 typelibs are unavailable — leave the environment
1825
# untouched; this only fixes the version when it can.
1926
pass
27+
28+
# Gramps warns once per addon that lacks a compiled locale dir (source-tree
29+
# addons ship ``po/*.po`` only), plus a startup warning about the main locale
30+
# dir — expected here, not a failure. Raising the logger to ERROR is robust:
31+
# logging levels are not reset by the test runners, so this holds on every
32+
# entrypoint.
33+
logging.getLogger(".gramps.gen.utils.grampslocale").setLevel(logging.ERROR)
34+
# PyGObject deprecation shout from gramps.gui.glade on import — not our concern.
35+
# NOTE: this warning *filter* is honoured by ``make.py test`` (TextTestRunner),
36+
# but a bare ``python -m unittest`` resets warning filters mid-run and may still
37+
# print it once. The documented entrypoint (make.py) stays clean; suppressing it
38+
# everywhere would mean importing gramps.gui.glade eagerly here (pulls in Gtk on
39+
# every run), which isn't worth it for one cosmetic line.
40+
warnings.filterwarnings("ignore", message=r".*shouldn't use __slots__.*")

tests/gramps_test_env.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,47 @@ def _has_gtk() -> bool:
9292
_plugin_cache: dict[str, Any] = {}
9393

9494

95+
# Dialog classes an addon might instantiate at import / load-on-reg time.
96+
_GUI_DIALOG_NAMES = (
97+
"ErrorDialog",
98+
"WarningDialog",
99+
"OkDialog",
100+
"InfoDialog",
101+
"QuestionDialog",
102+
"QuestionDialog2",
103+
"DBErrorDialog",
104+
"RunDatabaseRepair",
105+
)
106+
107+
def neutralize_gui_dialogs() -> None:
108+
"""Replace ``gramps.gui.dialog`` dialogs with no-ops for the test process.
109+
110+
Some addons pop a *blocking* modal (``ErrorDialog(...).run()``) at import or
111+
load-on-reg time when an optional dependency is missing — e.g.
112+
``lxmlGramplet`` on a host without ``python3-lxml``. Loading such an addon
113+
in a test would otherwise hang on the modal (under a display) or scatter
114+
dialogs on the developer's screen. Safe to call more than once.
115+
"""
116+
try:
117+
import gramps.gui.dialog as gd
118+
except Exception:
119+
return
120+
121+
class _NoDialog:
122+
def __init__(self, *args: Any, **kwargs: Any) -> None:
123+
self.response = 0
124+
125+
def run(self, *args: Any, **kwargs: Any) -> int:
126+
return 0
127+
128+
def __getattr__(self, name: str) -> Any:
129+
return lambda *a, **k: None
130+
131+
for name in _GUI_DIALOG_NAMES:
132+
if hasattr(gd, name):
133+
setattr(gd, name, _NoDialog)
134+
135+
95136
def get_plugin_manager_and_registry() -> tuple[Any, Any]:
96137
"""Return the Gramps plugin manager and registry, initialising on first call.
97138
@@ -105,6 +146,9 @@ def get_plugin_manager_and_registry() -> tuple[Any, Any]:
105146
from gramps.gen.const import PLUGINS_DIR
106147
from gramps.gen.plug import BasePluginManager, PluginRegister
107148

149+
# reg_plugins(load_on_reg=True) runs addon load-on-reg callbacks in
150+
# this process; neutralise dialogs first so none can block or show UI.
151+
neutralize_gui_dialogs()
108152
pmgr = BasePluginManager.get_instance()
109153
pmgr.reg_plugins(PLUGINS_DIR, None, None)
110154
pmgr.reg_plugins(ADDONS_ROOT, None, None, load_on_reg=True)
@@ -123,6 +167,24 @@ def make_gramps_user() -> Any:
123167
return User(auto_accept=True, quiet=True)
124168

125169

170+
def strict_mode() -> bool:
171+
"""Whether ``GRAMPS_ADDON_TEST_STRICT`` opts into gating on advisory results.
172+
173+
Off by default so a headless developer run is not flaky on failures that
174+
are really about the environment (no display, missing GI/typelib, a native
175+
GTK abort). A CI lane with a full runtime can set the variable to promote
176+
those advisories to hard failures — see the ``make.py test`` docs.
177+
178+
:returns: ``True`` when the variable is set to a truthy value.
179+
"""
180+
return os.environ.get("GRAMPS_ADDON_TEST_STRICT", "").lower() in (
181+
"1",
182+
"true",
183+
"yes",
184+
"on",
185+
)
186+
187+
126188
# ------------------------------------------------------------
127189
#
128190
# GrampsTestCase

tests/test_addon_dependency_loading.py

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
# ------------------------
5757
# Gramps specific
5858
# ------------------------
59-
from tests.gramps_test_env import GrampsTestCase
59+
from tests.gramps_test_env import GrampsTestCase, strict_mode
6060
from tests.test_plugin_registration import _get_addon_plugins
6161

6262
LOG = logging.getLogger(__name__)
@@ -92,6 +92,20 @@
9292
# -I so PYTHONPATH is ignored, and a run-from-source Gramps would
9393
# otherwise be unimportable. addon-from-addon isolation is unaffected.
9494
sys.path[:0] = [{target_dir!r}] + list({dep_dirs!r}) + list({base_dirs!r})
95+
# Neutralise blocking modal dialogs so an addon that pops
96+
# ErrorDialog(...).run() at import (missing optional dep) can't hang us.
97+
try:
98+
import gramps.gui.dialog as _gd
99+
class _NoDialog:
100+
def __init__(self, *a, **k): self.response = 0
101+
def run(self, *a, **k): return 0
102+
def __getattr__(self, n): return lambda *a, **k: None
103+
for _n in ("ErrorDialog", "WarningDialog", "OkDialog", "InfoDialog",
104+
"QuestionDialog", "QuestionDialog2"):
105+
if hasattr(_gd, _n):
106+
setattr(_gd, _n, _NoDialog)
107+
except Exception:
108+
pass
95109
try:
96110
importlib.import_module({module!r})
97111
except BaseException:
@@ -150,6 +164,8 @@ def _missing_modules(stderr: str) -> set[str]:
150164
return {m.split(".")[0] for m in re.findall(r"No module named ['\"]([^'\"]+)", stderr)}
151165

152166

167+
168+
153169
# ------------------------------------------------------------
154170
#
155171
# TestAddonDependencyLoading
@@ -267,6 +283,7 @@ def test_declared_dependencies_are_load_bearing(self) -> None:
267283
base_dirs = [_gramps_root()]
268284
addon_modules = self._addon_module_names()
269285
insufficient: list[str] = [] # declared deps do NOT satisfy a sibling import
286+
inconclusive: list[str] = [] # load failed environmentally — unverifiable
270287
load_bearing = 0 # dependents whose dependency is provably needed at load
271288
for pdata in dependents:
272289
dep_dirs: list[str] = []
@@ -292,12 +309,8 @@ def test_declared_dependencies_are_load_bearing(self) -> None:
292309
f"not satisfy sibling import(s) {sorted(unmet)}"
293310
)
294311
else:
295-
LOG.info(
296-
"%s: isolated load failed for a non-dependency reason "
297-
"(environment); cannot verify its depends_on. Tail: %s",
298-
pdata.id,
299-
(err_with.strip().splitlines() or ["<none>"])[-1],
300-
)
312+
tail = (err_with.strip().splitlines() or ["<none>"])[-1]
313+
inconclusive.append(f"{pdata.id}: {tail}")
301314
continue
302315

303316
rc_without, err_without = _isolated_load(
@@ -319,18 +332,47 @@ def test_declared_dependencies_are_load_bearing(self) -> None:
319332
pdata.id,
320333
)
321334

335+
if inconclusive:
336+
# The cause is environmental (GI/display/missing pip module), not a
337+
# depends_on bug, so by default this is advisory — surfaced loudly
338+
# and by count so an unverified dependent is never invisible behind
339+
# an overall pass. A CI job that guarantees a full runtime (Xvfb,
340+
# all deps) can set GRAMPS_ADDON_TEST_STRICT=1 to make any
341+
# unverified dependent a failure instead.
342+
LOG.warning(
343+
"%d/%d dependent(s) could not be verified — isolated load "
344+
"failed for environmental reasons (GI/display/missing pip "
345+
"module):\n %s",
346+
len(inconclusive),
347+
len(dependents),
348+
"\n ".join(inconclusive),
349+
)
350+
if strict_mode():
351+
self.fail(
352+
f"{len(inconclusive)} of {len(dependents)} dependent(s) "
353+
"could not be verified and strict mode "
354+
"(GRAMPS_ADDON_TEST_STRICT) is on:\n "
355+
+ "\n ".join(inconclusive)
356+
)
357+
322358
if insufficient:
323359
self.fail(
324-
"Addons whose declared depends_on does NOT satisfy their "
325-
"sibling imports (the loader would fail to load these):\n "
360+
f"{len(insufficient)} of {len(dependents)} dependent(s) have a "
361+
"declared depends_on that does NOT satisfy their sibling "
362+
"imports (the loader would fail to load these):\n "
326363
+ "\n ".join(insufficient)
364+
+ f"\n\nRun summary: {len(dependents)} dependents, "
365+
f"{load_bearing} verified load-bearing, "
366+
f"{len(inconclusive)} inconclusive (environmental), "
367+
f"{len(insufficient)} insufficient."
327368
)
328369
self.assertGreater(
329370
load_bearing,
330371
0,
331372
"No addon demonstrated a load-time depends_on: every dependent "
332-
"imported fine without its declared dependency, so this test "
333-
"verified nothing about the loading mechanism.",
373+
"either imported fine without its declared dependency or could "
374+
f"not be verified ({len(inconclusive)} inconclusive), so this "
375+
"test verified nothing about the loading mechanism.",
334376
)
335377

336378

tests/test_plugin_load_gate.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,36 @@ def _fake_plugin() -> SimpleNamespace:
9494
requires_gi=[],
9595
)
9696

97-
def _run_load_test_with(self, run_result: SimpleNamespace):
97+
def _run_load_test_with(
98+
self,
99+
run_result: SimpleNamespace,
100+
missing_deps: list | None = None,
101+
strict: bool = False,
102+
):
98103
"""Drive ``test_load_all_addon_modules`` with one synthetic plugin.
99104
100-
The synthetic addon has no declared dependencies, so it is not skipped;
101105
``subprocess.run`` is stubbed to ``run_result`` to simulate a chosen load
102-
outcome. Returns the exception the production method raised, or ``None``
103-
if it returned without raising (the silent-pass behaviour).
106+
outcome; ``_check_dependencies`` returns ``missing_deps`` (empty by
107+
default, so the plugin is not skipped). ``GRAMPS_ADDON_TEST_STRICT`` is
108+
pinned — to ``"1"`` when ``strict`` else ``""`` — so the outcome never
109+
depends on the ambient environment. Returns the exception the production
110+
method raised, or ``None`` if it returned without raising.
104111
"""
105112
loader = prod.TestPluginLoading("test_load_all_addon_modules")
106113
with mock.patch.object(
107114
prod, "_get_addon_plugins", return_value=[self._fake_plugin()]
108115
), mock.patch.object(
109-
prod, "_check_dependencies", return_value=[]
116+
prod, "_check_dependencies", return_value=(missing_deps or [])
110117
), mock.patch.object(
111118
prod.subprocess, "run", return_value=run_result
119+
), mock.patch.dict(
120+
os.environ, {"GRAMPS_ADDON_TEST_STRICT": "1" if strict else ""}
121+
), mock.patch.object(
122+
# Silence the production advisory logger: every warning here is about
123+
# the synthetic fixture, so it must not leak next to the real tree's
124+
# warnings in the test output.
125+
prod,
126+
"LOG",
112127
):
113128
try:
114129
loader.test_load_all_addon_modules()
@@ -145,6 +160,56 @@ def test_clean_load_does_not_gate(self) -> None:
145160
outcome, f"a clean load must not gate, but raised: {outcome!r}"
146161
)
147162

163+
def test_strict_mode_gates_missing_dependency(self) -> None:
164+
"""In strict mode a declared-but-missing dependency must gate.
165+
166+
By default a dependency skip is advisory; with
167+
``GRAMPS_ADDON_TEST_STRICT=1`` the full-runtime gate must promote it to a
168+
hard failure. ``subprocess.run`` is never reached (the plugin is skipped
169+
before load), so the outcome is driven purely by the missing dependency.
170+
"""
171+
clean = SimpleNamespace(returncode=0, stderr="")
172+
missing = ["gi:GExiv2-0.10"]
173+
174+
default = self._run_load_test_with(clean, missing_deps=missing, strict=False)
175+
self.assertIsNone(
176+
default, "a dependency skip must stay advisory in default mode"
177+
)
178+
179+
strict = self._run_load_test_with(clean, missing_deps=missing, strict=True)
180+
self.assertIsInstance(
181+
strict,
182+
prod.TestPluginLoading.failureException,
183+
"strict mode must gate on a missing declared dependency",
184+
)
185+
self.assertIn("synthetic_broken_addon", str(strict))
186+
self.assertIn("unmet dependency", str(strict))
187+
188+
def test_strict_mode_gates_environmental_failure(self) -> None:
189+
"""In strict mode an environment-classified load failure must gate.
190+
191+
A GTK/display init failure is advisory by default (it names a known
192+
environmental signature); strict mode promotes it to a hard failure,
193+
since a full runtime should provide the display/GTK stack.
194+
"""
195+
env_fail = SimpleNamespace(
196+
returncode=1, stderr="RuntimeError: Gtk couldn't be initialized"
197+
)
198+
199+
default = self._run_load_test_with(env_fail, strict=False)
200+
self.assertIsNone(
201+
default, "an environmental failure must stay advisory in default mode"
202+
)
203+
204+
strict = self._run_load_test_with(env_fail, strict=True)
205+
self.assertIsInstance(
206+
strict,
207+
prod.TestPluginLoading.failureException,
208+
"strict mode must gate on an environment-classified failure",
209+
)
210+
self.assertIn("synthetic_broken_addon", str(strict))
211+
self.assertIn("environmental", str(strict))
212+
148213

149214
if __name__ == "__main__":
150215
unittest.main()

0 commit comments

Comments
 (0)