Zum Hauptinhalt springen

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.

Plugin testing requires Owncast v0.3.0

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

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.

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.

Owncat saysFeldnamen bleiben camelCase

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:

BehauptungWas es überprüft
chatSendsListe der owncast.chat.send-Strings (exakte Übereinstimmung, in Reihenfolge)
chatActionsListe der owncast.chat.sendAction-Strings
chatSystemsListe der owncast.chat.system-Strings
logsOrdered list of { plugin, level, message } entries from owncast.log. plugin is the manifest slug and level is info, warning, or error
chatToListe von { clientId, text } von owncast.chat.sendTo / replyTo
sseSendsBestellte Liste von { channel, event?, data? }vonowncast.sse.send(denevent/data` weglassen, um nur auf dem Kanal abzugleichen)
deletedMessagesNachrichten-IDs, die über owncast.chat.deleteMessage ausgeblendet wurden
kickedClientsClient-IDs, die über owncast.chat.kick getrennt wurden
discordPostsListe von Benachrichtigungsstrings von Discord
browserPushesListe von { title, body, url }-Browser-Push-Nutzdaten
fediversePostsList of { type, body?, image?, link? } payloads sent via owncast.notifications.fediverse
fediverseOutboxList of owncast.fediverse.post strings (exact match, in order)
userRegistrationsList of { authId, displayName?, scopes?, profileUrl?, handle?, public? } from owncast.users.register, in order. authId is always checked. Other fields are checked when present
sessionGrantsList of { userId, ttl? } from owncast.auth.grantSession (ttl is checked only when non-zero)
sessionClearsNumber of owncast.auth.endSession calls
userModerationsListe von { userId, enabled, reason } von owncast.users.setEnabled
bannedIPsListe von IPs, die über owncast.users.banIP gesperrt wurden
uploadsList 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
videoConfigWritesListe von teilweisen Konfigurationen, die über owncast.videoConfig.write() angewendet wurden
emitsList of { eventType, payload } for owncast.events.emit calls. eventType is the exact fully qualified target passed by the plugin
commandsList 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)
kvTeilweise Abbildung des Plugin-Konfigurationsstatus nach dem Szenario
httpRequestsList 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.

FeldSteuerungen
given.kvVorabfüllung des Schlüssel-Wert-Speichers Ihres Plugins (owncast.kv)
given.configAdmin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults
given.streamWas owncast.stream.current() zurückgibt
given.broadcasterWas owncast.stream.broadcaster() zurückgibt
given.serverWas owncast.server.info() zurückgibt
given.socialsWas owncast.server.socials() zurückgibt
given.federationWas owncast.server.federation() zurückgibt
given.tagsWas owncast.server.tags() zurückgibt
given.videoConfigWas owncast.videoConfig.read() zurückgibt
given.chatHistoryWas owncast.chat.history() zurückgibt
given.chatClientsWas owncast.chat.clients() zurückgibt
given.usersWas owncast.users.list() / .get(id) zurückgibt
given.httpResponsesVorgegebene 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.

npm run serve
# override the port:
PORT=8765 npm run serve

Ü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/chat mit {"user":"alice","body":"hi"}: führt deine Chat-Nachrichtenfilterkette aus und feuert dann chat.message.received ab. 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/event mit {"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.

Contributors to this documentation
Gabe KangasGabe Kangas
O
Owncast
G
Gabe Kangas