Passer au contenu principal

Tester les plugins

Les plugins Owncast sont livrés avec un cadre de test basé sur les scénarios qui fait fonctionner votre plugin construit à travers le vrai moteur de plugin Owncast, avec les effets secondaires (envois de chat, récupérations HTTP, écritures de configuration) capturés pour les assertions. Un test réussi signifie le même comportement en production.

Plugin testing requires Owncast v0.3.0

Plugins require Owncast 0.3.0 or later.

Les scénarios sont des données brutes, donc le modèle de scénario sur cette page est identique peu importe la langue que vous utilisez. Les fichiers de test se trouvent sous __tests__/. Comment vous les écrivez et les exécutez diffère légèrement selon le SDK.

Écriture et exécution des tests

Écrivez des fichiers __tests__/*.test.js qui appellent 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'] },
},
]);

Exécutez-les avec npm test. Parce que c'est un script, vous pouvez construire le tableau de scénarios avec des boucles, des fixtures et des charges utiles calculées. Divisez les scénarios en plusieurs fichiers __tests__/*.test.js et exécutez-les tous en une seule passe avec runScenarioFiles(). Des fichiers statiques __tests__/*.test.json fonctionnent aussi.

L'exécution des tests construit votre plugin, puis exécute tous les fichiers de scénario sous __tests__/. Le modèle de données du scénario est le même quel que soit le SDK que vous utilisez.

Owncat saysLes noms de champs restent en camelCase

Un scénario décrit les événements de l'hôte, pas votre code de plugin, donc les champs de charge utile utilisent les noms des fils (displayName, clientId) peu importe la langue dans laquelle vous avez écrit le plugin.

Anatomie d'un scénario

{
"name": "human-readable description",
"given": {},
"events": [],
"expect": {}
}
  • name: ce que teste le scénario. Affiché dans la sortie de réussite/échec.
  • given: optionnel. État initial que votre plugin lit (historique de chat, valeurs kv, infos serveur, réponses HTTP préenregistrées).
  • events: les étapes à exécuter, dans l'ordre. Chaque étape correspond à un envoi de notification, une invocation de chaîne de filtres ou une requête HTTP.
  • expect: assertions d'état final (après chaque étape). Quels messages de chat ont été postés, quelles requêtes HTTP ont été envoyées, ce qui a été écrit dans kv, et ainsi de suite.

Types d'étapes

event: notification fire-and-forget

Déclenche une notification vers le gestionnaire d'événements correspondant. 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"
}
}

Les types d'événements courants incluent chat.message.received, chat.user.joined, stream.started et stream.stopped. Les scénarios Fediverse peuvent déclencher fediverse.follow, fediverse.like, fediverse.repost, fediverse.quote, fediverse.mention, fediverse.reply, ou le catch-all brut fediverse.activity. La liste complète reflète la référence des gestionnaires.

filter: invocation de chaîne avec assertion en ligne

Envoie un message de chat dans votre filtre de message de chat et vérifie le résultat. L'expect ici est par étape, affirmant sur le FilterResult :

{
"filter": "chat.message.received",
"payload": { "user": { "id": "u-alice", "displayName": "alice" }, "body": "hello damn world" },
"expect": { "action": "modify", "payload": { "body": "hello **** world" } }
}

Ou pour affirmer un rejet :

{
"filter": "chat.message.received",
"payload": { "user": { "id": "u-alice", "displayName": "alice" }, "body": "buy crypto" },
"expect": { "action": "drop", "reason": "spam keyword" }
}

action est l'un de "pass", "modify", "drop".

http: envoyer une requête HTTP à travers votre plugin

{
"http": {
"method": "GET",
"path": "/api/status",
"expect": { "status": 200, "body": "{\"ok\":true}" }
}
}

Les en-têtes et le corps sont optionnels :

{
"http": {
"method": "POST",
"path": "/admin/api/save",
"headers": { "content-type": "application/json" },
"body": "{\"value\":42}",
"authenticated": true,
"expect": { "status": 200 }
}
}

authCheck: re-valider une session de portail

Pour les plugins auth.gate, entraîne le gestionnaire onAuthCheck directement avec une identité de spectateur résolue et affirme le 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.

Étapes de contenu

tabContent, pageContent, pageStyles, et pageScripts appellent directement le gestionnaire de contenu correspondant et affirment le code retourné, CSS ou 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.

Assertions d'état final

L'expect de haut niveau du scénario vérifie ce qui s'est passé pendant l'exécution :

AssertionCe qu'il vérifie
chatSendsListe des chaînes owncast.chat.send (correspondance exacte, dans l'ordre)
chatActionsListe des chaînes owncast.chat.sendAction
chatSystemsListe des chaînes owncast.chat.system
logsOrdered list of { plugin, level, message } entries from owncast.log. plugin is the manifest slug and level is info, warning, or error
chatToListe de { clientId, text } de owncast.chat.sendTo / replyTo
sseSendsListe ordonnée de { channel, event?, data? }deowncast.sse.send(omettreevent/data` pour correspondre uniquement au canal)
deletedMessagesID de message masqués via owncast.chat.deleteMessage
kickedClientsID de client déconnectés via owncast.chat.kick
discordPostsListe des chaînes de notification Discord
browserPushesListe de { title, body, url } des charges utiles de notifications push navigateur
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 de { userId, enabled, reason } de owncast.users.setEnabled
bannedIPsListe des IPs bannies via owncast.users.banIP
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 des configurations partielles appliquées via owncast.videoConfig.write()
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)
kvCarte partielle de l'état de configuration du plugin après le scénario
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" }]
}
}

Les chatSends (et les autres assertions de chat) capturent les publications de n'importe quelle étape : y compris le chat que votre plugin envoie depuis l'intérieur d'un gestionnaire de requêtes HTTP, et pas seulement ceux des gestionnaires d'événements.

owncast.fs.* (le bac à sable storage.fs) n'a pas d'assertion dédiée : le moteur le soutient avec un véritable bac à sable en mémoire pendant les tests, donc testez-le de la manière dont vous l'utiliseriez : faites conduire les propres points de terminaison (ou gestionnaires) de votre plugin et affirmez ce qu'ils retournent. Par exemple, POST un fichier via votre point de terminaison de chargement, puis GET votre point de terminaison de liste et vérifiez que la réponse l'inclut. L'exemple file-manager fait exactement cela.

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.

Exemple exerçant plusieurs :

{
"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 } }]
}
}

Mise en place de l'état avec given

Chaque champ given.* contrôle ce que retourne une lecture d'hôte spécifique. Combinez-les pour mettre votre plugin dans n'importe quel état que vous voulez.

ChampContrôles
given.kvPré-remplissez le magasin clé/valeur de votre plugin (owncast.kv)
given.configAdmin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults
given.streamCe que retourne owncast.stream.current()
given.broadcasterCe que retourne owncast.stream.broadcaster()
given.serverCe que retourne owncast.server.info()
given.socialsCe que retourne owncast.server.socials()
given.federationCe que retourne owncast.server.federation()
given.tagsCe que retourne owncast.server.tags()
given.videoConfigCe que retourne owncast.videoConfig.read()
given.chatHistoryCe que retourne owncast.chat.history()
given.chatClientsCe que retourne owncast.chat.clients()
given.usersCe que retourne owncast.users.list() / .get(id)
given.httpResponsesRéponses préenregistrées pour les appels sortants owncast.http.fetch

Exemple :

{
"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)"]
}
}

Réponses HTTP préenregistrées

Pour les plugins qui appellent owncast.http.fetch, given.httpResponses est un tableau de réponses préenregistrées. Chaque fixture est un objet plat : url (un glob, par exemple https://api.foo.com/*), méthode optionnelle, statut, en-têtes optionnels et corps.

{
"given": {
"httpResponses": [
{
"url": "https://api.ipify.org?format=json",
"status": 200,
"body": "{\"ip\":\"203.0.113.42\"}"
}
]
}
}

Un dispositif correspond à url glob (et à method, si défini). 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. Si votre plugin effectue un appel pour lequel aucun dispositif ne correspond, le cadre échoue le scénario afin que vous sachiez ajouter un cas.

Auth dans les scénarios HTTP

Par défaut, les étapes HTTP sont traitées comme non authentifiées. Pour exercer les points de terminaison administratifs, définissez authenticated: true:

{
"http": {
"method": "GET",
"path": "/admin/api/settings",
"authenticated": true,
"expect": { "status": 200 }
}
}

Pour les points de terminaison de chat-token-utilisateur, définissez user :

{
"http": {
"method": "GET",
"path": "/my-data",
"user": { "id": "u1", "displayName": "alice", "scopes": ["MODERATOR"] },
"expect": { "status": 200 }
}
}

Sans aucun des deux indicateurs, les requêtes vers les chemins administratifs déclarés dans le manifeste retournent 401 avant que votre code de plugin ne s'exécute. Utile pour affirmer que la porte d'authentification fonctionne :

{
"http": {
"method": "GET",
"path": "/admin/index.html",
"expect": { "status": 401 }
}
}

Vitesse et isolation

  • Chaque scénario obtient une nouvelle instance de plugin et une configuration propre en mémoire. L'état ne fuit pas d'un scénario à l'autre.
  • Les tests sont rapides. Un fichier de test typique avec reconstruction se termine en quelques secondes. Exécutez-les à chaque sauvegarde.
  • Aucun vrai Owncast requis. L'environnement d'exécution est inclus avec le SDK, donc vous n'avez pas besoin d'un serveur pour tester.

Serveur de développement local

Pour une itération interactive, exécutez un serveur de développement local qui charge votre plugin et le sert à http://localhost:8080/plugins/\<your-slug>/ : curl vos points de terminaison, ouvrez des pages statiques dans un navigateur, ou pilotez vos gestionnaires d'événements et de filtres.

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

Au-delà des fichiers statiques et de vos routes HTTP, il expose des points de terminaison réservés aux développeurs pour piloter les gestionnaires qu'un serveur HTTP standard ne peut pas atteindre. Les lectures d'hôte (informations sur le serveur, configuration vidéo, etc.) retournent des données de développement d'échantillon.

  • POST /_dev/chat avec {"user":"alice","body":"hi"} : exécute votre chaîne de filtre de message de chat, puis déclenche chat.message.received. La réponse JSON montre ce que votre filtre a fait.
  • GET /_dev/chat : le journal de chat jusqu'à présent, y compris tout ce que votre plugin a posté.
  • POST /_dev/event avec {"type":"stream.started","payload":{}} : dispatch un événement arbitraire à vos gestionnaires.

Redémarrez le serveur de développement lorsque vous modifiez votre code. Utilisez les tests de scénario pour des assertions répétables. Le serveur de développement est pour l'itération interactive. De nombreux auteurs exécutent les deux : serveur de développement dans un terminal, observateur de test dans un autre.


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