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

TestChecksRuns without
Boundary checkthe Python half imports only layers A, B and CStudio (reads source with ast)
Python halfthe Plugin loads, activates and registers what it shoulda running ProjectServer
Web halfthe module activates against a fake host and its entries attacha 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-studio

It reads every *.py file with ast (it never imports them) and fails on:

  • any witwin_server import other than witwin_server.api, witwin_server.api.testing, and witwin_server.plugins_builtin.<name>.api for a listed studio.<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 api module.

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.

HelperDoes
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
SceneTimeServicea 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 None

The 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 sdkVersion 2.0.0, its id is the manifest's, and it has no import statement and no fetch(, new WebSocket, localhost or http://;
  • with the Console off, the Plugin activates and its console.channels entry waits quietly;
  • with the Console on, the entry attaches and shows the lines the fake rpc returned;
  • an installed copy (not a local path) may not use the proposed slots service.

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