Ir al contenido principal

Pruebas de plugins

Los plugins de Owncast vienen con un marco de pruebas basado en escenarios que ejecuta tu plugin construido a través del runtime real de Owncast, con los efectos secundarios (envíos de chat, peticiones HTTP, escrituras de configuración) capturados para afirmaciones. Una prueba que pasa significa el mismo comportamiento en producción.

Plugin testing requires Owncast v0.3.0

Plugins require Owncast 0.3.0 or later.

Los escenarios son datos simples, así que el modelo de escenario en esta página es idéntico sin importar el idioma en el que escribas. Los archivos de prueba se encuentran bajo __tests__/. Cómo los escribes y los ejecutas varía ligeramente según el SDK.

Escribir y ejecutar pruebas

Escribe archivos __tests__/*.test.js que llamen a 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'] },
},
]);

Ejecuta con npm test. Porque es un script, puedes construir el arreglo de escenarios con bucles, fixtures y cargas útiles calculadas. Divide los escenarios entre varios archivos __tests__/*.test.js y ejecútalos todos en una sola pasada con runScenarioFiles(). Los archivos estáticos __tests__/*.test.json también funcionan.

Ejecutar las pruebas construye tu plugin, luego ejecuta cada archivo de escenario bajo __tests__/. El modelo de datos de escenario es el mismo sin importar qué SDK utilices.

Owncat saysLos nombres de los campos se mantienen en camelCase

Un escenario describe eventos del host, no tu código de plugin, así que los campos de carga útil utilizan los nombres de los cables (displayName, clientId) sin importar el idioma en el que escribiste el plugin.

Anatomía de un escenario

{
"name": "human-readable description",
"given": {},
"events": [],
"expect": {}
}
  • name: qué prueba el escenario. Se muestra en la salida de paso/fallo.
  • given: opcional. Establece el estado inicial que tu plugin lee (historial de chat, valores de kv, información del servidor, respuestas HTTP enlatadas).
  • events: los pasos a seguir, en orden. Cada paso es una notificación despachada, invocación de cadena de filtros o petición HTTP.
  • expect: afirmaciones de estado final (después de que se ejecutan todos los pasos). Qué mensajes de chat se publicaron, qué peticiones HTTP se realizaron, qué se escribió en kv, etc.

Tipos de paso

event: notificación de fuego-y-olvidar

Despacha una notificación al controlador de eventos correspondiente. 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"
}
}

Los tipos de eventos comunes incluyen chat.message.received, chat.user.joined, stream.started, y stream.stopped. Los escenarios de Fediverse pueden despachar fediverse.follow, fediverse.like, fediverse.repost, fediverse.quote, fediverse.mention, fediverse.reply, o el fediverse.activity que captura todo. La lista completa refleja la referencia de controladores.

filter: invocación de cadena con afirmación en línea

Envía un mensaje de chat a tu filtro de mensajes y verifica el resultado. El expect aquí es por paso, afirmando en el FilterResult:

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

O para afirmar una eliminación:

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

action es uno de "pass", "modify", "drop".

http: enviar una petición HTTP a través de tu plugin

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

Los encabezados y el cuerpo son opcionales:

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

authCheck: re-validar una sesión de gate

Para plugins de auth.gate, impulsa el controlador de onAuthCheck directamente con una identidad de espectador resuelta y afirma el veredicto:

{
"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.

Pasos de contenido

tabContent, pageContent, pageStyles, y pageScripts llaman al controlador de contenido correspondiente directamente y afirman sobre el marcado, CSS o JavaScript devueltos:

{ "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.

Afirmaciones de estado final

El expect de nivel superior del escenario verifica qué ocurrió durante toda la ejecución:

AfirmaciónQué verifica
chatSendsLista de cadenas de owncast.chat.send (coincidencia exacta, en orden)
chatActionsLista de cadenas de owncast.chat.sendAction
chatSystemsLista de cadenas de 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
chatToLista de { clientId, text } de owncast.chat.sendTo / replyTo
sseSendsLista ordenada de { channel, event?, data? }deowncast.sse.send(omiteevent/data` para coincidir solo por canal)
deletedMessagesIDs de mensajes ocultos a través de owncast.chat.deleteMessage
kickedClientsIDs de clientes desconectados a través de owncast.chat.kick
discordPostsLista de cadenas de notificación de Discord
browserPushesLista de cargas útiles de notificaciones del navegador { title, body, url }
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
userModerationsLista de { userId, enabled, reason } de owncast.users.setEnabled
bannedIPsLista de IPs prohibidas a través de 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
videoConfigWritesLista de configuraciones parciales aplicadas a través de 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)
kvMapa parcial del estado de configuración del plugin después del escenario
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 (y las otras afirmaciones de chat) capturan publicaciones de cualquier paso: incluyendo chat que tu plugin envía desde dentro de un controlador de solicitud HTTP, no solo desde controladores de eventos.

owncast.fs.* (el sandbox de storage.fs) no tiene una afirmación dedicada: el runtime lo respalda con un sandbox real en memoria durante las pruebas, así que pruébalo como lo usarías: impulsa los propios endpoints (o controladores) de tu plugin y afirma sobre lo que devuelven. Por ejemplo, POST un archivo a través de tu endpoint de carga, luego GET tu endpoint de lista y afirma que la respuesta lo incluye. El ejemplo del file-manager hace exactamente esto.

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.

Ejemplo que ejercita varios:

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

Sembrando estado con given

Cada campo given.* controla lo que devuelve una lectura específica del host. Combina estos para poner tu plugin en cualquier estado que desees.

CampoControles
given.kvPre-pobla la tienda de clave/valor de tu plugin (owncast.kv)
given.configAdmin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults
given.streamLo que devuelve owncast.stream.current()
given.broadcasterLo que devuelve owncast.stream.broadcaster()
given.serverLo que devuelve owncast.server.info()
given.socialsLo que devuelve owncast.server.socials()
given.federationLo que devuelve owncast.server.federation()
given.tagsLo que devuelve owncast.server.tags()
given.videoConfigLo que devuelve owncast.videoConfig.read()
given.chatHistoryLo que devuelve owncast.chat.history()
given.chatClientsLo que devuelve owncast.chat.clients()
given.usersLo que devuelve owncast.users.list() / .get(id)
given.httpResponsesRespuestas enlatadas para llamadas salientes de owncast.http.fetch

Ejemplo:

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

Respuestas HTTP enlatadas

Para plugins que llaman a owncast.http.fetch, given.httpResponses es un arreglo de respuestas enlatadas. Cada fixture es un objeto plano: url (un glob, por ejemplo, https://api.foo.com/*), method opcional, status, headers opcionales, y body.

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

Una fixture coincide por url glob (y method, si se establece). 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 tu plugin realiza una llamada que ninguna fixture coincide, el marco falla el escenario para que sepas que debes añadir un caso.

Autenticación en escenarios HTTP

Por defecto, los pasos HTTP se tratan como no autenticados. Para ejercer puntos finales de administración, establece authenticated: true:

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

Para puntos finales de token de usuario de chat, establece user:

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

Sin ninguna de las dos banderas, las solicitudes a rutas de administración declaradas en el manifiesto devuelven 401 antes de que se ejecute tu código de plugin. Útil para afirmar que el control de autenticación funciona:

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

Velocidad e aislamiento

  • Cada escenario obtiene una nueva instancia de plugin y una configuración limpia en memoria. El estado no se filtra de un escenario al siguiente.
  • Las pruebas son rápidas. Un archivo de prueba típico con reconstrucción termina en unos pocos segundos. Ejecuta las pruebas en cada guardado.
  • No se requiere Owncast real. El runtime está empaquetado con el SDK, por lo que no necesitas un servidor para probar.

Servidor de desarrollo local

Para iteraciones interactivas, ejecuta un servidor de desarrollo local que carga tu plugin y lo sirve en http://localhost:8080/plugins/\<your-slug>/: curl tus puntos finales, abre páginas estáticas en un navegador, o controla tus manejadores de eventos y de filtro.

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

Más allá de los archivos estáticos y tus rutas HTTP, expone puntos finales solo para desarrolladores para controlar los manejadores que un servidor HTTP normal no puede alcanzar. Las lecturas de host (información del servidor, configuración de video, etc.) devuelven datos de desarrollo de ejemplo.

  • POST /_dev/chat con {"user":"alice","body":"hi"}: ejecuta tu cadena de filtros de mensajes de chat, luego activa chat.message.received. La respuesta JSON muestra lo que hizo tu filtro.
  • GET /_dev/chat: el registro de chat hasta ahora, incluyendo cualquier cosa que tu plugin haya publicado.
  • POST /_dev/event con {"type":"stream.started","payload":{}}: despacha un evento arbitrario a tus manejadores.

Reinicia el servidor de desarrollo cuando cambies tu código. Usa pruebas de escenario para afirmaciones repetibles. El servidor de desarrollo es para iteración interactiva. Muchos autores ejecutan ambos: el servidor de desarrollo en una terminal, el observador de pruebas en otra.


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