Testing plugins
Owncast plugins ship with a scenario-based testing framework that drives your built plugin through the real Owncast plugin runtime, with the side effects (chat sends, HTTP fetches, config writes) captured for assertions. A passing test means the same behavior in production.
Plugins require Owncast 0.3.0 or later.
Scenarios are plain data, so the scenario model on this page is identical whatever language you write in. Test files live under __tests__/. How you write and run them differs slightly by SDK.
Writing and running tests
- JavaScript
- Python
Write __tests__/*.test.js files that call runScenarios([...]):
const { runScenarios } = require('@owncast/plugin-sdk/testing');
runScenarios([
{
name: 'echoes the message',
events: [
{
event: 'chat.message.received',
payload: { user: { id: 'u1', displayName: 'alice' }, body: 'hi' },
},
],
expect: { chatSends: ['alice said: hi'] },
},
]);
Run them with npm test. Because it's a script, you can build the scenario array with loops, fixtures, and computed payloads. Split scenarios across several __tests__/*.test.js files and run them all in one pass with runScenarioFiles(). Static __tests__/*.test.json files work too.
Write __tests__/*.test.json files containing an array of scenarios (the format shown throughout this page):
[
{
"name": "echoes the message",
"events": [
{
"event": "chat.message.received",
"payload": { "user": { "id": "u1", "displayName": "alice" }, "body": "hi" }
}
],
"expect": { "chatSends": ["alice said: hi"] }
}
]
Run them with owncast-plugin-py test (the directory argument defaults to the current one). Python uses the JSON format: there's no script-based runner.
Running the tests builds your plugin, then runs every scenario file under __tests__/. The scenario data model is the same whichever SDK you use.
A scenario describes host events, not your plugin code, so payload fields use the wire names (displayName, clientId) regardless of the language you wrote the plugin in.
Anatomy of a scenario
{
"name": "human-readable description",
"given": {},
"events": [],
"expect": {}
}
name: what the scenario tests. Shown in pass/fail output.given: optional. Seed initial state your plugin reads from (chat history, kv values, server info, canned HTTP responses).events: the steps to run, in order. Each step is one notification dispatch, filter chain invocation, or HTTP request.expect: final-state assertions (after every step runs). What chat messages were posted, what HTTP requests went out, what was written to kv, and so on.
Step types
event: fire-and-forget notification
Dispatches a notification to the matching event handler.
{
"event": "chat.message.received",
"payload": {
"user": { "id": "u1", "displayName": "alice" },
"clientId": 1,
"body": "hi",
"timestamp": "2026-01-01T00:00:00Z"
}
}
Common event types include chat.message.received, chat.user.joined, stream.started, and stream.stopped. Fediverse scenarios can dispatch fediverse.follow, fediverse.like, fediverse.repost, fediverse.quote, fediverse.mention, fediverse.reply, or the raw fediverse.activity catch-all. The full list mirrors the handlers reference.
filter: chain invocation with inline assertion
Sends a chat message into your chat-message filter and checks the result. The expect here is per-step, asserting on the FilterResult:
{
"filter": "chat.message.received",
"payload": { "user": { "id": "u-alice", "displayName": "alice" }, "body": "hello damn world" },
"expect": { "action": "modify", "payload": { "body": "hello **** world" } }
}
Or to assert a drop:
{
"filter": "chat.message.received",
"payload": { "user": { "id": "u-alice", "displayName": "alice" }, "body": "buy crypto" },
"expect": { "action": "drop", "reason": "spam keyword" }
}
action is one of "pass", "modify", "drop".
http: send an HTTP request through your plugin
{
"http": {
"method": "GET",
"path": "/api/status",
"expect": { "status": 200, "body": "{\"ok\":true}" }
}
}
Headers and body are optional:
{
"http": {
"method": "POST",
"path": "/admin/api/save",
"headers": { "content-type": "application/json" },
"body": "{\"value\":42}",
"authenticated": true,
"expect": { "status": 200 }
}
}
authCheck: re-validate a gate session
For auth.gate plugins, drives the onAuthCheck handler directly with a resolved viewer identity and asserts the verdict:
{
"authCheck": {
"user": { "id": "u1", "displayName": "Alice" },
"expect": { "action": "deny", "reason": "access revoked" }
}
}
action is "ok", "refresh", or "deny". reason is optional and matched exactly when set.
Content steps
tabContent, pageContent, pageStyles, and pageScripts call the matching content handler directly and assert on the returned markup, CSS, or JavaScript:
{ "tabContent": { "slug": "schedule", "expect": { "bodyContains": "Friday" } } }
tabContent and pageContent take a slug and an optional user. In production, Owncast passes a manifest.tabs object key to onTabContent and manifest.extraPageContent.slug to onPageContent. Scenario steps call these handlers directly, so the slug can be arbitrary when testing fallback behavior for an unknown slug. All four steps accept expect.body (exact) or expect.bodyContains.
Final-state assertions
The scenario's top-level expect checks what happened across the whole run:
| Assertion | What it checks |
|---|---|
chatSends | List of owncast.chat.send strings (exact match, in order) |
chatActions | List of owncast.chat.sendAction strings |
chatSystems | List of owncast.chat.system strings |
logs | Ordered list of { plugin, level, message } entries from owncast.log. plugin is the manifest slug and level is info, warning, or error |
chatTo | List of { clientId, text } from owncast.chat.sendTo / replyTo |
sseSends | Ordered list of { channel, event?, data? } from owncast.sse.send (omit event/data to match only on channel) |
deletedMessages | Message IDs hidden via owncast.chat.deleteMessage |
kickedClients | Client IDs disconnected via owncast.chat.kick |
discordPosts | List of Discord notification strings |
browserPushes | List of { title, body, url } browser-push payloads |
fediversePosts | List of { type, body?, image?, link? } payloads sent via owncast.notifications.fediverse |
fediverseOutbox | List of owncast.fediverse.post strings (exact match, in order) |
userRegistrations | List of { authId, displayName?, scopes?, profileUrl?, handle?, public? } from owncast.users.register, in order. authId is always checked. Other fields are checked when present |
sessionGrants | List of { userId, ttl? } from owncast.auth.grantSession (ttl is checked only when non-zero) |
sessionClears | Number of owncast.auth.endSession calls |
userModerations | List of { userId, enabled, reason } from owncast.users.setEnabled |
bannedIPs | List of IPs banned via owncast.users.banIP |
uploads | List of { name, body?, bodyBase64? } from owncast.storage.upload. name is always checked. Non-empty body values compare text. Present bodyBase64 values compare exact decoded bytes |
videoConfigWrites | List of partial configs applied via owncast.videoConfig.write() |
emits | List of { eventType, payload } for owncast.events.emit calls |
commands | List of { name, prefix?, description?, usage?, aliases?, modOnly, caseSensitive, cooldownMs } chat-command registrations, matched by name in any order (prefix, description, usage, and aliases are checked only when set) |
kv | Partial map of plugin-config state after the scenario |
httpRequests | List of { url, method?, body? } outbound owncast.http.fetch calls. url is an exact match, an omitted method matches any, an omitted body skips the check |
Use the camelCase wire names in userRegistrations for both JavaScript and Python scenarios. displayName, profileUrl, and handle are compared whenever supplied, including when set to "". scopes is compared whenever supplied. [] expects no scopes and matches either an omitted or empty actual list. Non-empty arrays match exactly. public is compared whenever supplied, so false asserts that the plugin kept the identity private. Omit any of these fields to skip its check.
{
"expect": {
"userRegistrations": [
{
"authId": "github:583231",
"displayName": "octocat",
"profileUrl": "https://github.com/octocat",
"handle": "octocat",
"public": false
}
]
}
}
Use body for text uploads. It is checked only when its value is non-empty, so omitting it or setting it to "" skips the body check. Use bodyBase64 for exact byte comparisons. It is checked whenever supplied and accepts standard base64 with or without padding. An empty bodyBase64 value ("") decodes to zero bytes and asserts an empty upload. If both fields contain checked values, both comparisons run.
{
"expect": {
"uploads": [{ "name": "invalid-utf8.bin", "bodyBase64": "/wCA" }]
}
}
chatSends (and the other chat assertions) capture posts from any step: including chat your plugin sends from inside an HTTP-request handler, not just from event handlers.
owncast.fs.* (the storage.fs sandbox) has no dedicated assertion: the runtime backs it with a real in-memory sandbox during tests, so test it the way you'd use it: drive your plugin's own endpoints (or handlers) and assert on what they return. For example, POST a file through your upload endpoint, then GET your list endpoint and assert the response includes it. The file-manager example does exactly this.
owncast.sql.* works the same way. The test runner and the dev server give each plugin a real in-memory SQLite database, so there's no SQL assertion and no given.sql: every scenario starts with an empty database and your plugin creates its own schema on first use. Drive the handlers or commands that write, then assert on what the ones that read send back. The same statements are refused there as on a real server and the same per-call limits apply, so a scenario that passes runs the same SQL in production. The chat-leaderboard example (JavaScript, Python) is tested exactly this way: chat events count messages, then !top and !rank report the standings.
Example exercising several:
{
"name": "bumps the counter and broadcasts an event",
"events": [
{
"event": "chat.message.received",
"payload": { "user": { "id": "u-alice", "displayName": "alice" }, "body": "hi" }
},
{
"event": "chat.message.received",
"payload": { "user": { "id": "u-alice", "displayName": "alice" }, "body": "hi again" }
}
],
"expect": {
"chatSends": ["alice: 1 message", "alice: 2 messages"],
"kv": { "count:u-alice": "2" },
"emits": [{ "eventType": "milestone.reached", "payload": { "user": "alice", "count": 2 } }]
}
}
Seeding state with given
Each given.* field controls what a specific host read returns. Combine these to put your plugin in any state you want.
| Field | Controls |
|---|---|
given.kv | Pre-populate your plugin's key/value store (owncast.kv) |
given.config | Admin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults |
given.stream | What owncast.stream.current() returns |
given.broadcaster | What owncast.stream.broadcaster() returns |
given.server | What owncast.server.info() returns |
given.socials | What owncast.server.socials() returns |
given.federation | What owncast.server.federation() returns |
given.tags | What owncast.server.tags() returns |
given.videoConfig | What owncast.videoConfig.read() returns |
given.chatHistory | What owncast.chat.history() returns |
given.chatClients | What owncast.chat.clients() returns |
given.users | What owncast.users.list() / .get(id) returns |
given.httpResponses | Canned responses for outbound owncast.http.fetch calls |
Example:
{
"name": "answers !uptime when the stream is live",
"given": {
"stream": { "online": true, "startedAt": "2026-05-28T14:00:00Z", "viewers": 12 }
},
"events": [
{
"event": "chat.message.received",
"payload": {
"user": { "id": "u-alice", "displayName": "alice" },
"body": "!uptime",
"timestamp": "2026-05-28T14:01:30Z"
}
}
],
"expect": {
"chatSends": ["uptime: 90s, 12 viewer(s)"]
}
}
Canned HTTP responses
For plugins that call owncast.http.fetch, given.httpResponses is an array of canned responses. Each fixture is a flat object: url (a glob, e.g. https://api.foo.com/*), optional method, status, optional headers, and body.
{
"given": {
"httpResponses": [
{
"url": "https://api.ipify.org?format=json",
"status": 200,
"body": "{\"ip\":\"203.0.113.42\"}"
}
]
}
}
A fixture matches by url glob (and method, if set). The first matching fixture wins and serves any number of calls. Fixtures aren't consumed, so a sequence where the same URL must answer differently across calls (a 401 followed by a 200 after a token refresh, say) can't be modeled. Unit-test that branch outside the runner. If your plugin makes a call that no fixture matches, the framework fails the scenario so you know to add a case.
Auth in HTTP scenarios
By default, HTTP steps are treated as unauthenticated. To exercise admin endpoints, set authenticated: true:
{
"http": {
"method": "GET",
"path": "/admin/api/settings",
"authenticated": true,
"expect": { "status": 200 }
}
}
For chat-user-token endpoints, set user:
{
"http": {
"method": "GET",
"path": "/my-data",
"user": { "id": "u1", "displayName": "alice", "scopes": ["MODERATOR"] },
"expect": { "status": 200 }
}
}
Without either flag, requests to manifest-declared admin paths return 401 before your plugin code runs. Useful for asserting the auth gate works:
{
"http": {
"method": "GET",
"path": "/admin/index.html",
"expect": { "status": 401 }
}
}
Speed and isolation
- Each scenario gets a fresh plugin instance and a clean in-memory config. State doesn't leak from one scenario to the next.
- Tests are fast. A typical test file with rebuild finishes in a few seconds. Run them on every save.
- No real Owncast required. The runtime is bundled with the SDK, so you don't need a server to test.
Local dev server
For interactive iteration, run a local dev server that loads your plugin and serves it at http://localhost:8080/plugins/<your-slug>/: curl your endpoints, open static pages in a browser, or drive your event and filter handlers.
- JavaScript
- Python
npm run serve
# override the port:
PORT=8765 npm run serve
owncast-plugin-py serve my-plugin
# override the port:
owncast-plugin-py serve my-plugin -p 8765
Beyond static files and your HTTP routes, it exposes dev-only endpoints to drive the handlers a plain HTTP server can't reach. Host reads (server info, video config, and so on) return sample dev data.
POST /_dev/chatwith{"user":"alice","body":"hi"}: runs your chat-message filter chain, then fireschat.message.received. The JSON response shows what your filter did.GET /_dev/chat: the chat log so far, including anything your plugin posted.POST /_dev/eventwith{"type":"stream.started","payload":{}}: dispatch an arbitrary event to your handlers.
Restart the dev server when you change your code. Use scenario tests for repeatable assertions. The dev server is for interactive iteration. Many authors run both: dev server in one terminal, test watcher in another.
Improve this page
See something missing or incorrect? Edit this page and improve the documentation for everyone.
Related Documents
- Configuration via PluginsLet admins configure your plugin with typed settings. Owncast renders the form, you read the values at runtime with owncast.config.get.
- Extend Owncast with pluginsWrite plugins that run sandboxed inside Owncast to react to chat, post to the fediverse, serve HTTP endpoints, and add UI.
- Serving HTTP via PluginsServe URLs from your plugin, write dynamic handlers, gate admin endpoints, and push realtime events to browsers.
- Chat pluginsBuild chat bots, moderation tools, and message filters for Owncast plugins using chat handlers and owncast.chat APIs.
- Python SDKAuthor Owncast plugins in Python with owncast-plugin-py: install, the @plugin decorators, the owncast-plugin-py CLI, and testing.
- Contributing web UI with PluginsAdd admin pages to the Owncast admin UI and action buttons to the viewer chrome.
