Testing¶
1. Unit tests on your own machine¶
The host interfaces only exist inside Mumcord, so a plugin crate cannot call them in a normal
cargo test. Keep the logic in plain Rust and put the host calls behind a small trait:
trait Host {
fn get_count(&self) -> u32;
fn set_count(&self, n: u32);
fn send_wave(&self);
}
fn handle_wave(host: &impl Host) -> u32 {
let n = host.get_count().saturating_add(1);
host.set_count(n);
host.send_wave();
n
}
The real implementation calls storage and peers; tests use a fake that records calls. Test the
message encoding, limits, bad input from peers and every error path (NotPermitted,
RateLimited, NoRecipients). The official Poll plugin (poll/ in this repository) is built this
way and is a good example. Compile the crate for the host target for tests
(cargo test --lib), and keep host-only code behind #[cfg(target_arch = "wasm32")].
2. Check the package¶
check reports exactly what the client would reject: manifest errors, unknown or forbidden
permissions, missing English texts, oversized files, wasm that imports anything but
mumcord:plugin/*. Fix these first.
3. Scenario tests¶
mumcord-plugin test runs your plugin in the same runtime as the client against a mock host. A
scenario is a YAML file that sends events in and checks what comes out: UI trees, messages to
other users, chat lines, notifications and log lines. Nothing reaches the network or the keychain,
and every scenario starts with a fresh storage database.
The first argument is a built .mcplugin package or an unpacked plugin folder, followed by one or
more scenario files. Each scenario prints ok <name> or FAIL <name> with the reason, and the
command exits with an error if any scenario failed. mumcord-plugin new creates a working
scenarios/hello.yaml for you. A short example:
name: Waving counts and tells the channel
grant: [ui.panel, peers.messages]
me: { session: 1, name: Alice }
peers:
- { session: 2, name: Bob }
steps:
- expect: { tree: { slot: panel, text: "Waves: 0" } }
- event: { ui-action: { action: wave } }
- expect: { message: { to: channel, kind: 1, text: "null" } }
- expect: { tree: { slot: panel, text: "Waves: 1" } }
- event: { peer-message: { from: 2, kind: 1, text: "null" } }
- expect: { log: { contains: someone waved } }
- expect: nothing
Top-level keys (unknown keys are rejected):
| Key | Meaning |
|---|---|
name |
Shown in the result. Defaults to the file path. |
grant |
Granted permissions. Default: the required ones plus the suggested optional ones. |
me |
You: {session, name, anonymous}. Default: session 1, "Me". anonymous: true = no key. |
peers |
Other users in the channel who run the same plugin, same fields as me. |
language |
UI language, default en. |
theme |
light (default) or dark. |
steps |
The steps, run in order. |
A step is one of:
event:sends an event to the plugin. Kinds:ui-action(action, optionalslot,commandfor a typed slash command,value),peer-message(from,kind,textorhex, optionaldirect),joined,left,talking(session,talking),screen-share-started,chat-activity,mentioned,profile-card-opened,user-menu-requested(each with a session number),chat-trigger(sender,trigger,args) andchat-match(sender,text), both with the chat options described below,panel-visibility(hidden,inlineorexpanded),dialog-closedandlanguage.wait:pauses for the given milliseconds, for example to let a timer fire.expect:checks the next output. It waits up to 1 s for a match after the previous one. Kinds:tree(slot, optionaltext,nodes),cleared(a slot),message(to:channelor a session number,kind,textorhex),chat(text),notification(title,body),dialog(title),menu-entry(label),replacedandhidden(see below),log(contains),stopped(optionalerror) andnothing(no output for a moment).
Slots are written panel, settings, dialog, or with an argument: member-badge: 2,
profile-card: 2, chat-decoration: 2, chat-card: <id>. Text fields match as substrings;
message payloads match exactly.
Chat messages in scenarios¶
chat-trigger and chat-match steps stand in for a user's chat line. The trigger word must be one
of the manifest triggers, and for chat-match the first manifest pattern that matches text
sets the pattern index.
| Option | Meaning |
|---|---|
message: N |
Which chat message the line is. Defaults to the next unused number (1, 2, ...). The same number twice is the same message. expect steps use it to name the message. |
replay: true |
The line comes from stored history, so chat.is-replay is true. As in the real host, only plugins that import is-replay get it. |
own: true |
(chat-match only) Your plugin's own [Name] line replayed from history, delivered with pattern index 255. Needs chat.send in the granted permissions; the step is refused without it. sender is not used. |
Two more expect kinds check what the plugin did to a message:
replaced: {message: N, card: ID}the plugin calledchat.replacefor message N (cardis optional: any card matches when left out).hidden: {message: N}the plugin calledchat.hidefor message N.
steps:
- event: { chat-trigger: { sender: 2, trigger: "!poll", args: "2", message: 4 } }
- expect: { hidden: { message: 4 } }
# The same line again, now from history: nothing is counted twice
- event: { chat-trigger: { sender: 2, trigger: "!poll", args: "2", message: 4, replay: true } }
- expect: { hidden: { message: 4 } }
- event: { chat-match: { text: "[Poll] Lunch?", own: true, message: 5, replay: true } }
- expect: { replaced: { message: 5, card: poll } }
Preview the UI¶
mumcord-plugin preview renders the UI your plugin shows into one static HTML page, so you can
look at it without starting Mumcord:
mumcord-plugin preview dist/my-plugin-0.1.0.mcplugin --scenario scenarios/hello.yaml --out preview.html
--scenario plays the events and waits of a scenario first (its expect steps are not checked);
without it, you see the UI right after start. --out defaults to preview.html. The page follows
your system's light or dark theme, loads nothing and contains no scripts. It approximates Mumcord's
components at the real slot sizes, so use it for layout checks and confirm the final look in
Mumcord itself. The command fails if the plugin shows no UI.
4. Load it in Mumcord¶
Switch on Developer mode in Mumcord, then load the project folder or package through the plugin settings. Developer plugins:
- may use all permissions except the three official-only ones,
- are marked as developer plugins, and exchange messages only with other developer copies of the same id,
- show their log lines and call statistics in the plugin's diagnostics.
Test with two Mumcord clients (two copies on the same machine are fine) joined to the same local test server. Test only against a local Mumble server, never a public one.
Things to try by hand:
- Install with only the required permissions, then turn each optional permission on and off.
- Alone in the channel (
NoRecipientsis normal), and with a second client. - Light and dark theme, large text, reduced motion.
- Disable and enable the plugin; restart Mumcord; reconnect to the server.
- Update from a previous version (set
previous-versionhandling).
5. A local test registry¶
To test the install and update flow, serve a registry on your machine:
mumcord-plugin keygen --unencrypted --out keys --name test # test key only
mumcord-plugin serve-registry --packages dist --key keys/test.key
This serves your packages as the official registry on http://127.0.0.1:<port>/v1/, signed with the
test key. Point a development build of Mumcord at it with the registry mirror setting and
MUMCORD_PLUGIN_REGISTRY_KEY=<public key line>. Add --repository <name> to serve a third-party
repository instead (see Third-party repositories).
serve-registry can also produce what the client must refuse, which is useful to see how your
update flow behaves: tampered-hash:<id>, lower-sequence, wrong-key, extra-permission:<id>,
revoke:<id>[@<ver>][:warn] and key-change[:rotate]. Switch them on with --scenario or while
running with POST /_ctl/scenario?set=a,b, and publish new versions with POST /_ctl/reload. The
scenario table is in
plugin-registry-format.md.
Never use a real signing key with the test registry, and never commit any key.