Plugin testkit
graph/plugins/testkit.py — a host-free harness that loads a plugin exactly the way the runtime does (same synthetic package name, so relative imports and module-level @tool decorators work) while stubbing the parts of protoAgent that aren't installed.
That combination is the point: a standalone plugin repo can exercise its real modules in CI with no protoAgent present. python -m server plugin new --tests vendors this file into a new plugin as tests/_plugin_testkit.py; bundled plugins import it directly.
from graph.plugins.testkit import FakeRegistry, load_plugin
def test_register_binds_tools():
pkg = load_plugin(PLUGIN_DIR, 'my-plugin')
reg = FakeRegistry(plugin_id='my-plugin')
pkg.register(reg)
assert [t.name for t in reg.tools] == ['my_tool']Functions
install_host_stubs
install_host_stubs(extra: dict | None = None) -> list[str]Register stub host modules in sys.modules so a plugin's graph.* / knowledge.* imports resolve with no protoAgent present. Call BEFORE load_plugin (or before importing any plugin module that imports the host).
Idempotent and non-clobbering: a module that's already importable (a real one, or a stub from a previous call) is left untouched. extra is {module_name: {attr: val}} to add or override host modules your plugin needs. Returns the names newly installed.
load_plugin
load_plugin(root, plugin_id: str | None = None, *, entry: str = '__init__.py')Import a plugin directory as a PACKAGE and return the package module.
After this, the plugin's own relative imports resolve and you can reach its sibling modules — import <pkg>.fleet / getattr(pkg, "fleet") — to unit-test engine logic directly, exactly as the host loads it (under protoagent_plugin_<id> with the plugin dir on the package search path). Idempotent + reload-safe: re-loading purges the package AND its cached submodules so an edited sibling re-execs (mirrors the loader).
Args:
root— the plugin directory (where__init__.pylives).plugin_id— the plugin id; defaults to the directory name.entry— the entry module filename (__init__.pyorplugin.py).
plugin_module_name
plugin_module_name(plugin_id: str) -> strThe synthetic package name a plugin loads under — mirrors graph.plugins.loader._plugin_module_name (a hyphen in the module name breaks the relative-import machinery, so non-identifier chars become _).
FakeRegistry
Records what register(registry) contributes, with no host — mirrors the real graph.plugins.registry.PluginRegistry surface so a plugin's register() runs unchanged. Assert against the captured lists/dicts.
e.g. reg = FakeRegistry(); plugin.register(reg); assert reg.tools and reg.verifiers.
Parity contract: every public method on PluginRegistry (register_*, emit, on, navigate, live_config) must exist here with the same parameters — a missing method makes that seam silently untestable (a plugin's hasattr guard skips it and a typo'd registration ships green). Enforced by tests/test_plugin_testkit.py::test_fake_registry_mirrors_the_full_plugin_registry_surface — adding a seam to the registry without mirroring it here fails that test.
Capture shapes are assert-friendly, not the registry's internal shapes. One behavioral divergence, on purpose: where the real registry warns and skips a bad registration (degrade-safe live), the fake raises ValueError — a test harness must fail loud, not ship a silently-dropped registration green.
It mirrors every public method of PluginRegistrywith identical signatures — tests/test_plugin_testkit.py asserts that by introspection, so a new registry seam fails CI until the harness can exercise it. Calls land on attributes named after the seam (reg.tools, reg.routers, reg.surfaces, …) for your assertions.