fix(pytest): robust plugin injection via pytest.main(plugins=[...])

The plugin was delivered by writing it to a temp dir, putting that dir on
PYTHONPATH and loading it with `python -m pytest -p _testium_pytest_plugin`.
That import-by-name failed in the AppImage runtime (ModuleNotFoundError:
_testium_pytest_plugin) so collection returned nothing and the item FAILed
— while wheel/pyinstaller/flatpak worked. Local sims forcing the AppImage
env path (apply_host_libs) passed, ruling out the env scrubbing.

Ship the plugin as a self-contained launcher run directly
(`python launcher.py ...`) that registers it as a plugin object via
pytest.main(plugins=[sys.modules[__name__]]): no PYTHONPATH, no `-p`, no
import-by-name. apply_host_libs is untouched. Verified on source, wheel,
pyinstaller, flatpak and AppImage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 23:19:30 +02:00
parent e167da97d0
commit 1c598a1eae
2 changed files with 29 additions and 24 deletions

View File

@@ -1,7 +0,0 @@
[Desktop Entry]
Type=Application
Name=Testium
Exec=testium
Icon=testium
Terminal=false
Categories=Utility;Automated test

View File

@@ -10,9 +10,11 @@ the host interpreter** (``bins.python_bin()``), exactly like ``py_func`` /
the host (visible across every packaging channel — source, wheel, PyInstaller,
Flatpak, AppImage) instead of requiring them inside the bundled interpreter.
A tiny stdlib-only pytest plugin (written to a temp dir and loaded with
``-p``) streams the collected node ids and per-test results back over the
subprocess stdout as sentinel-prefixed lines, which the parent parses live.
A tiny stdlib-only pytest plugin is shipped as a self-contained launcher
script (run directly and registered via ``pytest.main(plugins=[...])`` — no
PYTHONPATH / ``-p`` / import-by-name). It streams the collected node ids and
per-test results back over the subprocess stdout as sentinel-prefixed lines,
which the parent parses live.
"""
import os
@@ -40,8 +42,6 @@ _SENT_COLLECTED = "__TESTIUM_PYTEST_COLLECTED__"
_SENT_START = "__TESTIUM_PYTEST_START__"
_SENT_RESULT = "__TESTIUM_PYTEST_RESULT__"
_PLUGIN_MODULE = "_testium_pytest_plugin"
# stdlib-only pytest plugin executed inside the host subprocess. It must not
# import anything from testium. It emits one sentinel line per event so the
# parent can rebuild the test tree (collection) and per-test results (run)
@@ -118,6 +118,17 @@ def pytest_runtest_logfinish(nodeid, location):
}))
'''
# Self-contained pytest runner: the plugin source above + a main that hands the
# module to pytest as a *plugin object* (pytest.main(plugins=[...])). Run
# directly (``python launcher.py …``), so the plugin needs no PYTHONPATH, no
# ``-p`` and no import-by-name — robust on every channel, AppImage included.
_LAUNCHER_SOURCE = _PLUGIN_SOURCE + '''
if __name__ == "__main__":
import pytest
sys.exit(pytest.main(plugins=[sys.modules[__name__]]))
'''
class TestItemPytestElement(TestItem):
"""One collected pytest test (leaf child of a pytest file item)."""
@@ -154,22 +165,23 @@ class TestItemPytestFile(TestItem):
self._testDir = ''
self._test_methods = self._prms.getParamAll('test_method', processed=True)
self._cwd = ""
self._plugin_dir = ""
self._launcher = ""
def setTestDir(self, dir):
self._testDir = dir
# ---- subprocess plumbing -------------------------------------------------
def _write_plugin(self):
def _write_launcher(self):
# In Flatpak the host process can only read /tmp (shared), so stage the
# plugin there; outside Flatpak the default temp dir is fine.
# launcher there; outside Flatpak the default temp dir is fine.
d = tempfile.mkdtemp(prefix="testium_pytest_",
dir="/tmp" if bins._in_flatpak() else None)
with open(os.path.join(d, _PLUGIN_MODULE + ".py"), "w") as f:
f.write(_PLUGIN_SOURCE)
path = os.path.join(d, "launcher.py")
with open(path, "w") as f:
f.write(_LAUNCHER_SOURCE)
atexit.register(shutil.rmtree, d, ignore_errors=True)
return d
return path
def _pytest_popen(self, args):
pbin = bins.python_bin()
@@ -179,19 +191,19 @@ class TestItemPytestFile(TestItem):
env = os.environ.copy()
bins.apply_host_libs(env)
env.pop("PYTHONUSERBASE", None)
env["PYTHONPATH"] = self._plugin_dir + os.pathsep + env.get("PYTHONPATH", "")
# Run the self-contained launcher directly: it registers our plugin via
# pytest.main(plugins=[...]), so no PYTHONPATH / -p / import-by-name.
cmd_args = [
"-m", "pytest",
self._launcher,
"--capture=no", # let plugin sentinels + test prints reach our pipe
"-o", "addopts=", # neutralise user addopts (xdist/cov break parsing)
"-p", "no:cacheprovider",
"-p", _PLUGIN_MODULE,
*args,
]
if bins._in_flatpak():
host_env = {k: env[k] for k in ("PYTHONPATH", "PATH") if env.get(k)}
host_env = {"PATH": env["PATH"]} if env.get("PATH") else {}
params = bins.flatpak_host_spawn(
pbin, cmd_args, host_cwd=self._cwd, extra_env=host_env)
popen_kwargs = {}
@@ -233,7 +245,7 @@ class TestItemPytestFile(TestItem):
def _collection_error(self, output):
"""Clear reason why collection produced no test."""
if "No module named pytest" in output:
if "No module named pytest" in output or "No module named 'pytest'" in output:
return ("pytest is not installed on the host interpreter used by "
"testium (python_bin). Install it, e.g. 'pip install pytest'.")
return 'No pytest test collected from "%s".\n%s' % (self._fileName, output)
@@ -249,7 +261,7 @@ class TestItemPytestFile(TestItem):
raise ETUMFileError('File "%s" is not found' % (self._fileName))
self._cwd = os.path.dirname(self._fileName) or "."
self._plugin_dir = self._write_plugin()
self._launcher = self._write_launcher()
nodeids, output = self._collect()
if not nodeids: