mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
test(plugins): pin the plugin_context contract before carving server.py (R3b) (#898)
tests/test_plugin_context_contract.py (3 tests). No production code changes. server.py is about to be carved apart around startup_events(), and `plugin_context` — the 20-key dict handed to every plugin's setup() — is built inline inside it. Issue #48 flagged this while planning the split and asked for exactly this guard: "Plugin context[...] are passed as live references into already-loaded plugins. Refactoring must preserve the exact callables — moving them to a new module is fine, but renaming or wrapping them breaks third-party plugins. We'd want a 'plugin context unchanged' assertion in CI." It never got written. Writing it FIRST, because a key silently dropped or renamed by a move is invisible to every other test in the suite — nothing in-tree reads most of these — and would break plugins at runtime, in the field. This is the backend's version of the window contract, and the frontend carve just taught me what that costs: 43 of library.js's exports were referenced ONLY from app.js's top-level window block, invisible to any call-graph scan, and trusting the scan would have shipped a dead A-Z rail with CI fully green. A contract only external code reads has to be pinned BY NAME, before the move, not after. THE SURFACE IS BIGGER THAN server.py's DICT. Shipped plugins read `log` and `load_sibling`, and neither is in it — plugins/__init__.py layers them on per-plugin. A test pinning only server.py's 18 keys would have missed both. ━━━ CODEX CAUGHT ME WRITING A VACUOUS ASSERTION ━━━ My first identity test built a dict locally and called setup() on it — asserting `dict(x)['k'] is x['k']`, which is trivially true and blind to everything the loader does. [P2], and correct. It now drives the REAL plugins.load_plugins() with a probe plugin, which matters: the loader DOES deliberately wrap one key (register_library_provider is scoped per-plugin so a plugin cannot forge owner attribution and impersonate another). The test pins that single intentional exception so it cannot quietly become two. Codex then caught [P2] number two: my hand-rolled teardown restored only PLUGINS_DIR and LOADED_PLUGINS, while load_plugins() also mutates sys.path, sys.modules and PENDING_PLUGINS — order- and environment-dependent. tests/test_plugins.py already had a fixture that does this properly, so `reset_plugin_state` moved to tests/conftest.py: ONE copy, shared, rather than a second that will drift. BITE-TESTED IN FIVE DIRECTIONS — drop a key, rename a key, drop a per-plugin key, wrap extract_meta in the loader (all key names intact, identity broken), and remove the register_library_provider scoping (the impersonation guard). Each fails. pytest 2399, Codex 0. Refs #48 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
756588678b
commit
1cef01d02c
@@ -1,6 +1,8 @@
|
||||
"""Shared pytest fixtures for the feedBack test suite."""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import structlog
|
||||
@@ -76,3 +78,61 @@ def isolate_logging():
|
||||
lg.setLevel(original_level)
|
||||
lg.propagate = original_propagate
|
||||
structlog.reset_defaults()
|
||||
|
||||
|
||||
# ── Plugin-loader isolation ─────────────────────────────────────────────────────
|
||||
#
|
||||
# Lifted verbatim out of tests/test_plugins.py so more than one test module can drive
|
||||
# the real plugins.load_plugins(). It has to be ONE fixture, not a copy per file:
|
||||
# load_plugins() mutates sys.path, sys.modules, PENDING_PLUGINS and LOADED_PLUGINS, and a
|
||||
# partial restore makes the suite order- and environment-dependent (Codex [P2] on
|
||||
# test_plugin_context_contract.py — it was right).
|
||||
|
||||
# Bare module names that this test module pre-populates into
|
||||
# sys.modules to simulate the bare-import path. Saved/restored by
|
||||
# the reset_plugin_state fixture so they don't leak to other test
|
||||
# files. Codex / Copilot review on PR for feedBack#33.
|
||||
_BARE_NAMES_USED = ("util", "extractor")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def reset_plugin_state(monkeypatch):
|
||||
"""Clear loader module-level state and restore on teardown.
|
||||
|
||||
Saves and restores:
|
||||
* `plugins.LOADED_PLUGINS`
|
||||
* any `plugin_*` keys we add to `sys.modules`
|
||||
* the bare names this module simulates (`util`, `extractor`)
|
||||
* `sys.path` — `plugins.load_plugins()` mutates it
|
||||
Also unsets `FEEDBACK_PLUGINS_DIR` for the test's duration
|
||||
(via monkeypatch) so a CI env that pre-sets it can't leak
|
||||
real user plugins into a tmp_path-driven test. Per-module
|
||||
locks are owned by the standard import system
|
||||
(`importlib._bootstrap._module_locks`) and are not our
|
||||
responsibility to reset.
|
||||
"""
|
||||
monkeypatch.delenv("FEEDBACK_PLUGINS_DIR", raising=False)
|
||||
plugins = importlib.import_module("plugins")
|
||||
saved_loaded = list(plugins.LOADED_PLUGINS)
|
||||
saved_pending = dict(plugins.PENDING_PLUGINS)
|
||||
saved_modules = {k: v for k, v in sys.modules.items() if k.startswith("plugin_")}
|
||||
saved_bare = {k: sys.modules[k] for k in _BARE_NAMES_USED if k in sys.modules}
|
||||
saved_path = list(sys.path)
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.PENDING_PLUGINS.clear()
|
||||
for k in list(sys.modules):
|
||||
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
|
||||
del sys.modules[k]
|
||||
try:
|
||||
yield plugins
|
||||
finally:
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.LOADED_PLUGINS.extend(saved_loaded)
|
||||
plugins.PENDING_PLUGINS.clear()
|
||||
plugins.PENDING_PLUGINS.update(saved_pending)
|
||||
for k in list(sys.modules):
|
||||
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
|
||||
del sys.modules[k]
|
||||
sys.modules.update(saved_modules)
|
||||
sys.modules.update(saved_bare)
|
||||
sys.path[:] = saved_path
|
||||
|
||||
Reference in New Issue
Block a user