Testing a Plugin
Test a Plugin with the boundary check, the witwin_server.api.testing helpers for its Python half and a fake host for its web half.
Every Plugin repository runs three kinds of test. The example Plugin in
server/tests/fixtures/plugins/witwin_example_notes/ has one of each, but
its tests live in Studio, not next to it: the fixture has no tests/ folder
and no pyproject.toml. Its Python tests are
server/tests/test_example_plugin.py; its web test is
studio/src/app/pluginHost/examplePlugin.test.ts. A real Plugin repository
keeps the same tests in its own tests/.
| Test | Checks | Runs without |
|---|---|---|
| Boundary check | the Python half imports only layers A, B and C | Studio (reads source with ast) |
| Python half | the Plugin loads, activates and registers what it should | a running ProjectServer |
| Web half | the module activates against a fake host and its entries attach | a browser or a server |
A fourth, the dual-mode end-to-end test, runs in Studio's repository for system Plugins.
The boundary check#
Copy server/scripts/plugin_api_boundary.py from witwin-studio into your
repository as tests/test_plugin_api_boundary.py, unchanged. Studio's
tests/test_backend_architecture.py checks that every first-party copy is
identical, but only when WITWIN_PLUGIN_REPOS lists the Plugin repository
roots (separated by os.pathsep); without it that test is skipped. Run by
hand:
python tests/test_plugin_api_boundary.py
python server/scripts/plugin_api_boundary.py path/to/plugin # from witwin-studioIt reads every *.py file with ast (it never imports them) and fails on:
- any
witwin_serverimport other thanwitwin_server.api,witwin_server.api.testing, andwitwin_server.plugins_builtin.<name>.apifor a listedstudio.<name>; - a module of another Plugin that is not listed in
pluginDependencies; - a module of a listed Plugin other than its top package or its
apimodule.
It recognises another Plugin's package by the wt_ prefix or by its
PACKAGE_EXCEPTIONS table; the own package comes from pyproject.toml or
main. Do not silence a failure: if you need a name, ask for it to become
stable.
The Python half: witwin_server.api.testing#
Plugin tests use these helpers instead of the loader and registries, so Studio can move its internals without breaking Plugin test suites.
| Helper | Does |
|---|---|
load_plugin(root, *, activate=True, force_enabled=True) | a context manager: starts the system Plugins, loads the Plugin with Studio's real loader as source = "local" (no pip, no trust step), fires the * and onStartupFinished activation when activate is true, and unloads it on exit; yields the loaded Plugin (manifest, loaded, activated, error, context). A lazy Plugin with other activationEvents stays imported but not activated. |
read_manifest(path) | parses witwin.toml the way the loader does |
activate_system_plugins(project=None) | starts the system Plugins, for a Plugin that depends on one (load_plugin does this) |
component_types() | every registered component type, by name |
library_item_ids() | every registered Library item id |
agent_hooks(plugin_id) | a context manager with a private set of Agent hooks: yields (agent, hooks), where agent is the ctx.agent object and hooks reads back what was registered (request_policies(), system_texts(tool_names), ...). It gives no whole ctx, so put the hook registration in a function that takes agent and call that function from activate and from the test. |
select_tools(...) | the Agent loop's Tool selection, to test Tool tags and request policies |
SceneTimeService | a Scene time service, to test that a Tool refuses author writes during replay |
build_project_mesh_cache(project, asset) | imports a mesh asset the way the server does |
# tests/test_plugin.py
from pathlib import Path
from witwin_server.api import testing
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
def test_activates_and_registers_its_rpc():
with testing.load_plugin(PLUGIN_ROOT) as plugin:
assert plugin.activated and plugin.error is NoneThe loader puts the Plugin's parent folder on sys.path itself. A test that
also imports the Plugin's module directly (to call a function such as
read_notes) needs monkeypatch.syspath_prepend(str(PLUGIN_ROOT.parent))
first.
To test an RPC method, keep its logic in a plain function and call it with
ProjectFs(lambda: tmp_path) from witwin_server.api (the example's
read_notes(fs, params)). Test the pydantic models directly; they are the
contract the web half relies on.
Run the tests in the same interpreter as Studio's backend: pip install -e <witwin-studio>/sdk -e <witwin-studio>/server once, then python -m pytest.
The web half#
A web half is a plain module, so a test can import it, give it a fake host
and check what it registered. Studio's own system Plugin tests
(studio/src/app/pluginHost/*Plugin.test.tsx) run the real PluginRuntime
with fake services; studio/src/app/pluginHost/examplePlugin.test.ts does
the same for the example Plugin:
- the module exports
sdkVersion2.0.0, its id is the manifest's, and it has noimportstatement and nofetch(,new WebSocket,localhostorhttp://; - with the Console off, the Plugin activates and its
console.channelsentry waits quietly; - with the Console on, the entry attaches and shows the lines the fake
rpcreturned; - an installed copy (not a local path) may not use the proposed
slotsservice.
System Plugins that need a fuller fake kernel have their tests in
studio/src/app/pluginHost/systemPluginTests/ (shared fake:
fakeKernel.ts).
A third-party repository without Studio's jest setup can test the same way
with node --test: import the module, call activate with an object that
has services.get returning fakes, and assert on what the fakes received.
Dual-mode end-to-end (system Plugins)#
studio/e2e/ runs Playwright tests against the desktop app and against the
web build served through a Control Server. The system Plugin cases live in
studio/e2e/support/systemPluginCases.ts and run from both
resources.desktop.spec.ts and resources.control.spec.ts: turning a
system Plugin off and on from the Plugins page, and the replacer Plugin
witwin_example_console taking the place of studio.console. A system
Plugin that adds a panel adds its case there: the panel opens in both modes,
and disabling the Plugin removes it without errors. Run them with
cd studio && npm run e2e (it builds the web app and Electron first).
Before you finish#
python -m pytest # in the Plugin repository
cd studio && npm run lint:architecture # system Plugins: imports, network, kernel list
cd studio && npm run check:plugin-docs # changed @witwin/host, a slot or the skill