Skip to content

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.

python
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 ​

python
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 ​

python
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__.py lives).
  • plugin_id — the plugin id; defaults to the directory name.
  • entry — the entry module filename (__init__.py or plugin.py).

plugin_module_name ​

python
plugin_module_name(plugin_id: str) -> str

The 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.

Part of the protoLabs autonomous development studio.