Plugins testen
Owncast-Plugins werden mit einem szenarienbasierten Testframework geliefert, das Ihr erstelltes Plugin durch die echte Owncast-Plugin-Laufzeit steuert, wobei die Nebeneffekte (Chat-Nachrichten, HTTP-Anfragen, Konfigurationen) für die Behauptungen erfasst werden. Ein bestandener Test bedeutet das gleiche Verhalten in der Produktion.
Plugins require Owncast 0.3.0 or later.
Szenarien sind einfache Daten, sodass das Szenariomodell auf dieser Seite unabhängig von der verwendeten Sprache identisch ist. Testdateien befinden sich unter __tests__/. Wie Sie sie schreiben und ausführen, unterscheidet sich geringfügig je nach SDK.
Tests schreiben und ausführen
- JavaScript
- Python
Schreiben Sie __tests__/*.test.js-Dateien, die runScenarios([...]) aufrufen:
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'] },
},
]);
Führen Sie sie mit npm test aus. Da es sich um ein Skript handelt, können Sie das Szenario-Array mit Schleifen, Fixtures und berechneten Nutzdaten erstellen. Teilen Sie Szenarien auf mehrere __tests__/*.test.js-Dateien auf und führen Sie sie alle in einem Durchgang mit runScenarioFiles() aus. Statische __tests__/*.test.json-Dateien funktionieren ebenfalls.
Schreiben Sie __tests__/*.test.json-Dateien, die ein Array von Szenarien enthalten (das Format, das auf dieser Seite gezeigt wird):
[
{
"name": "echoes the message",
"events": [
{
"event": "chat.message.received",
"payload": { "user": { "id": "u1", "displayName": "alice" }, "body": "hi" }
}
],
"expect": { "chatSends": ["alice said: hi"] }
}
]
Führen Sie sie mit owncast-plugin-py test (das Verzeichnis-Argument ist standardmäßig das aktuelle). Python verwendet das JSON-Format: Es gibt keinen skriptbasierten Runner.
Das Ausführen der Tests erstellt Ihr Plugin und führt dann jede Szenariodatei unter __tests__/ aus. Das Szenariodatenmodell ist das gleiche, unabhängig davon, welches SDK Sie verwenden.
Ein Szenario beschreibt Hostereignisse, nicht Ihren Plugin-Code, sodass die Nutzdatenfelder die Drahtnamen (displayName, clientId) verwenden, unabhängig von der Sprache, in der Sie das Plugin geschrieben haben.
Anatomie eines Szenarios
{
"name": "human-readable description",
"given": {},
"events": [],
"expect": {}
}
name: was das Szenario testet. In der Ausgabe der Pass/Fail-Anzeige angezeigt.given: optional. Initialzustand, den Ihr Plugin liest (Chatverlauf, KV-Werte, Serverinformationen, vorgegebene HTTP-Antworten).events: die Schritte, die in Reihenfolge ausgeführt werden sollen. Jeder Schritt ist eine Benachrichtigungsübermittlung, Filterkettenausführung oder HTTP-Anforderung.expect: Schlussfolgerungen des Endzustands (nach jeder Schritt-Ausführung). Welche Chatnachrichten gepostet wurden, welche HTTP-Anfragen gesendet wurden, was in KV geschrieben wurde, und so weiter.
Schrittarten
event: Benachrichtigung ohne Gewährleistung
Übermittelt eine Benachrichtigung an den passenden Ereignishandler. For a custom hook, use
the fully qualified \<recipient-slug>.\<hook> target. The host strips the slug
before invoking the plugin's local handler.
{
"event": "chat.message.received",
"payload": {
"user": { "id": "u1", "displayName": "alice" },
"clientId": 1,
"body": "hi",
"timestamp": "2026-01-01T00:00:00Z"
}
}
Zu den häufigen Ereignistypen gehören chat.message.received, chat.user.joined, stream.started und stream.stopped. Fediverse-Szenarien können fediverse.follow, fediverse.like, fediverse.repost, fediverse.quote, fediverse.mention, fediverse.reply oder den allgemeinen fediverse.activity abgeben. Die vollständige Liste entspricht den Handlerreferenzen.
filter: Kettenausführung mit Inline-Behauptung
Sendet eine Chatnachricht in Ihren Chatnachrichtenfilter und überprüft das Ergebnis. Das expect hier ist pro Schritt und bezieht sich auf das FilterResult:
{
"filter": "chat.message.received",
"payload": { "user": { "id": "u-alice", "displayName": "alice" }, "body": "hello damn world" },
"expect": { "action": "modify", "payload": { "body": "hello **** world" } }
}
Oder um einen Drop zu bestätigen:
{
"filter": "chat.message.received",
"payload": { "user": { "id": "u-alice", "displayName": "alice" }, "body": "buy crypto" },
"expect": { "action": "drop", "reason": "spam keyword" }
}
action ist eines von "pass", "modify", "drop".
http: sendet eine HTTP-Anfrage über Ihr Plugin
{
"http": {
"method": "GET",
"path": "/api/status",
"expect": { "status": 200, "body": "{\"ok\":true}" }
}
}
Header und Körper sind optional:
{
"http": {
"method": "POST",
"path": "/admin/api/save",
"headers": { "content-type": "application/json" },
"body": "{\"value\":42}",
"authenticated": true,
"expect": { "status": 200 }
}
}
authCheck: eine Gatesitzung erneut validieren
Für auth.gate-Plugins steuert es den onAuthCheck-Handler direkt mit einer aufgelösten Viewer-Identität und behauptet das Urteil:
{
"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.
Inhaltsaktionen
tabContent, pageContent, pageStyles und pageScripts rufen den passenden Inhalts-Handler direkt auf und behaupten sich über das zurückgegebene Markup, CSS oder 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.
Schlussfolgerungen des Endzustands
Die oberste expect des Szenarios überprüft, was während des gesamten Ablaufs passiert ist:
| Behauptung | Was es überprüft |
|---|---|
chatSends | Liste der owncast.chat.send-Strings (exakte Übereinstimmung, in Reihenfolge) |
chatActions | Liste der owncast.chat.sendAction-Strings |
chatSystems | Liste der 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 | Liste von { clientId, text } von owncast.chat.sendTo / replyTo |
sseSends | Bestellte Liste von { channel, event?, data? }vonowncast.sse.send(denevent/data` weglassen, um nur auf dem Kanal abzugleichen) |
deletedMessages | Nachrichten-IDs, die über owncast.chat.deleteMessage ausgeblendet wurden |
kickedClients | Client-IDs, die über owncast.chat.kick getrennt wurden |
discordPosts | Liste von Benachrichtigungsstrings von Discord |
browserPushes | Liste von { title, body, url }-Browser-Push-Nutzdaten |
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 | Liste von { userId, enabled, reason } von owncast.users.setEnabled |
bannedIPs | Liste von IPs, die über owncast.users.banIP gesperrt wurden |
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 | Liste von teilweisen Konfigurationen, die über owncast.videoConfig.write() angewendet wurden |
emits | List of { eventType, payload } for owncast.events.emit calls. eventType is the exact fully qualified target passed by the plugin |
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 | Teilweise Abbildung des Plugin-Konfigurationsstatus nach dem Szenario |
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 (und die anderen Chat-Behauptungen) erfassen Posts aus jedem Schritt: einschließlich Chat, den Ihr Plugin aus einem HTTP-Anforderungs-Handler sendet, nicht nur von Ereignis-Handlern.
owncast.fs.* (der storage.fs-Sandbox) hat keine dedizierte Behauptung: die Laufzeit unterstützt es mit einer echten In-Memory-Sandbox während der Tests, testen Sie es also so, wie Sie es verwenden würden: steuern Sie die eigenen Endpunkte (oder Handler) Ihres Plugins und behaupten Sie, was sie zurückgeben. Zum Beispiel posten Sie eine Datei über Ihren Upload-Endpunkt, dann rufen Sie Ihr Listen-Endpunkt ab und behaupten, dass die Antwort es enthält. Das file-manager-Beispiel tut genau das.
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.
Beispiel, das mehrere verwendet:
{
"name": "bumps the counter and targets an achievement hook",
"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": "achievements.milestone.reached", "payload": { "user": "alice", "count": 2 } }]
}
}
Zustand mit given setzen
Jedes given.*-Feld steuert, was eine bestimmte Hostrückgabe liefert. Kombinieren Sie diese, um Ihr Plugin in jeden gewünschten Zustand zu versetzen.
| Feld | Steuerungen |
|---|---|
given.kv | Vorabfüllung des Schlüssel-Wert-Speichers Ihres Plugins (owncast.kv) |
given.config | Admin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults |
given.stream | Was owncast.stream.current() zurückgibt |
given.broadcaster | Was owncast.stream.broadcaster() zurückgibt |
given.server | Was owncast.server.info() zurückgibt |
given.socials | Was owncast.server.socials() zurückgibt |
given.federation | Was owncast.server.federation() zurückgibt |
given.tags | Was owncast.server.tags() zurückgibt |
given.videoConfig | Was owncast.videoConfig.read() zurückgibt |
given.chatHistory | Was owncast.chat.history() zurückgibt |
given.chatClients | Was owncast.chat.clients() zurückgibt |
given.users | Was owncast.users.list() / .get(id) zurückgibt |
given.httpResponses | Vorgegebene Antworten für ausgehende owncast.http.fetch-Anfragen |
Beispiel:
{
"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)"]
}
}
Vorgegebene HTTP-Antworten
Für Plugins, die owncast.http.fetch aufrufen, ist given.httpResponses ein Array vordefinierter Antworten. Jede Fixture ist ein flaches Objekt: url (ein Glob, z.B. https://api.foo.com/*), optional method, status, optional headers und body.
{
"given": {
"httpResponses": [
{
"url": "https://api.ipify.org?format=json",
"status": 200,
"body": "{\"ip\":\"203.0.113.42\"}"
}
]
}
}
Eine Fixture wird durch url-Glob (und method, falls gesetzt) gefunden. 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. Wenn dein Plugin einen Aufruf macht, der keiner Fixture entspricht, schlägt das Framework das Szenario fehl, damit du weißt, dass du einen Fall hinzufügen musst.
Authentifizierung in HTTP-Szenarien
Standardmäßig werden HTTP-Schritte als nicht authentifiziert behandelt. Um Admin-Endpunkte zu nutzen, setze authenticated: true:
{
"http": {
"method": "GET",
"path": "/admin/api/settings",
"authenticated": true,
"expect": { "status": 200 }
}
}
Für Chat-Benutzer-Token-Endpunkte setze user:
{
"http": {
"method": "GET",
"path": "/my-data",
"user": { "id": "u1", "displayName": "alice", "scopes": ["MODERATOR"] },
"expect": { "status": 200 }
}
}
Ohne ein Flags geben Anfragen an manifest-erklärte Admin-Pfade 401 zurück, bevor dein Plugin-Code ausgeführt wird. Nützlich, um zu überprüfen, ob das Authentifizierungsgate funktioniert:
{
"http": {
"method": "GET",
"path": "/admin/index.html",
"expect": { "status": 401 }
}
}
Geschwindigkeit und Isolation
- Jedes Szenario erhält eine frische Plugin-Instanz und eine saubere In-Memory-Konfiguration. Der Zustand tritt nicht von einem Szenario zum nächsten über.
- Die Tests sind schnell. Eine typische Testdatei mit Wiederaufbau wird in wenigen Sekunden abgeschlossen. Führe sie bei jedem Speichern aus.
- Kein echtes Owncast erforderlich. Die Laufzeit ist im SDK gebündelt, sodass du keinen Server zum Testen benötigst.
Lokaler Entwicklungsserver
Für interaktives Iterieren führe einen lokalen Entwicklungsserver aus, der dein Plugin lädt und es unter http://localhost:8080/plugins/\<your-slug>/ bereitstellt: rufe deine Endpunkte ab, öffne statische Seiten in einem Browser oder steuere deine Ereignis- und Filterhandler.
- 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
Über statische Dateien und deine HTTP-Routen hinaus, bieten sie Entwicklungs-exklusive Endpunkte, um die Handler zu steuern, die ein normaler HTTP-Server nicht erreichen kann. Host-Abfragen (Serverinformationen, Videoeinstellungen usw.) geben Beispiel-Entwicklungsdaten zurück.
POST /_dev/chatmit{"user":"alice","body":"hi"}: führt deine Chat-Nachrichtenfilterkette aus und feuert dannchat.message.receivedab. Die JSON-Antwort zeigt, was dein Filter gemacht hat.GET /_dev/chat: das Chatprotokoll bis jetzt, einschließlich allem, was dein Plugin gepostet hat.POST /_dev/eventmit{"type":"stream.started","payload":{}}: dispatches ein beliebiges Ereignis an deine Handler.
Starte den Entwicklungsserver neu, wenn du deinen Code änderst. Verwende Szenariotests für wiederholbare Assertions. Der Entwicklungsserver dient dem interaktiven Iterieren. Viele Autoren führen beides aus: Entwicklungsserver in einem Terminal, Testbeobachter in einem anderen.
Improve this page
See something missing or incorrect? Edit the English version of this page or help improve translations.
