Ir para o conteúdo principal

Testando plugins

Os plugins do Owncast vêm com uma estrutura de testes baseada em cenários que executa seu plugin compilado no runtime real de plugins do Owncast, com os efeitos colaterais (envios de chat, requisições HTTP, gravações de configuração) capturados para asserções. Um teste que passa garante o mesmo comportamento em produção.

Plugin testing requires Owncast v0.3.0

Plugins require Owncast 0.3.0 or later.

Cenários são dados simples, então o modelo de cenário nesta página é idêntico, qualquer que seja a linguagem em que você escreva. Arquivos de teste ficam em __tests__/. Como você os escreve e executa difere ligeiramente entre SDKs.

Escrevendo e executando testes

Escreva arquivos __tests__/*.test.js que chamam 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'] },
},
]);

Execute-os com npm test. Como é um script, você pode construir o array de cenários com loops, fixtures e cargas úteis computadas. Divida cenários em vários arquivos __tests__/*.test.js e execute todos de uma vez com runScenarioFiles(). Arquivos estáticos __tests__/*.test.json também funcionam.

Executar os testes compila seu plugin e então executa todos os arquivos de cenário sob __tests__/. O modelo de dados de cenário é o mesmo, qualquer que seja o SDK que você use.

Owncat saysNomes de campo na wire permanecem em camelCase

Um cenário descreve eventos do host, não o código do seu plugin, portanto os campos de payload usam os nomes do wire (displayName, clientId) independentemente da linguagem em que você escreveu o plugin.

Anatomia de um cenário

{
"name": "human-readable description",
"given": {},
"events": [],
"expect": {}
}
  • name: o que o cenário testa. Exibido na saída de sucesso/falha.
  • given: opcional. Semear o estado inicial que seu plugin lê (histórico de chat, valores kv, informações do servidor, respostas HTTP pré-definidas).
  • events: as etapas a serem executadas, em ordem. Cada etapa é um despacho de notificação, invocação de cadeia de filtros ou requisição HTTP.
  • expect: asserções do estado final (após todas as etapas serem executadas). Quais mensagens de chat foram postadas, quais requisições HTTP foram feitas, o que foi gravado em kv, e assim por diante.

Tipos de etapa

event: notificação 'fire-and-forget'

Despacha uma notificação para o manipulador de evento correspondente. 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"
}
}

Tipos comuns de eventos incluem chat.message.received, chat.user.joined, stream.started e stream.stopped. Cenários do Fediverse podem despachar fediverse.follow, fediverse.like, fediverse.repost, fediverse.quote, fediverse.mention, fediverse.reply ou o genérico fediverse.activity. A lista completa espelha a referência de manipuladores.

filter: invocação de cadeia com asserção inline

Envia uma mensagem para seu filtro de mensagem de chat e verifica o resultado. O expect aqui é por etapa, fazendo asserções sobre o FilterResult:

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

Ou para asserir um drop:

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

action é um de "pass", "modify", "drop".

http: envie uma requisição HTTP através do seu plugin

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

Cabeçalhos e corpo são opcionais:

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

authCheck: revalidar uma sessão do gate

Para plugins auth.gate, aciona diretamente o manipulador onAuthCheck com uma identidade de visualizador resolvida e verifica o veredito:

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

Etapas de conteúdo

tabContent, pageContent, pageStyles e pageScripts chamam o manipulador de conteúdo correspondente diretamente e fazem asserções sobre o markup, o CSS ou o JavaScript retornado:

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

Asserções do estado final

O expect de nível superior do cenário verifica o que aconteceu durante toda a execução:

AsserçãoO que verifica
chatSendsLista de strings owncast.chat.send (correspondência exata, em ordem)
chatActionsLista de strings owncast.chat.sendAction
chatSystemsLista de strings 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
chatToList of { clientId, text } from owncast.chat.sendTo / replyTo
sseSendsOrdered list of { channel, event?, data? } from owncast.sse.send (omit event/data to match only on channel)
deletedMessagesIDs de mensagens ocultados via owncast.chat.deleteMessage
kickedClientsIDs de cliente desconectados via owncast.chat.kick
discordPostsLista de strings de notificações do Discord
browserPushesLista de { title, body, url } payloads de push do navegador
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 banidos 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
videoConfigWritesLista de configurações parciais aplicadas 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)
kvMapa parcial do estado de configuração do plugin após o cenário
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 (e as outras asserções de chat) capturam posts de qualquer etapa: incluindo chat que seu plugin envia de dentro de um manipulador de requisição HTTP, não apenas de manipuladores de evento.

owncast.fs.* (o sandbox storage.fs) não tem asserção dedicada: o runtime o sustenta com um sandbox real em memória durante os testes, então teste-o da maneira que você o usaria: acione os endpoints (ou handlers) do seu plugin e faça asserções sobre o que eles retornam. Por exemplo, faça um POST de um arquivo através do seu endpoint de upload, então GET seu endpoint de listagem e asserte que a resposta o inclui. O file-manager example faz exatamente isso.

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.

Exemplo cobrindo vários:

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

Semear estado com given

Cada campo given.* controla o que uma leitura específica do host retorna. Combine-os para colocar seu plugin em qualquer estado que você quiser.

CampoControla
given.kvPré-popule o key/value store do seu plugin (owncast.kv)
given.configAdmin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults
given.streamO que owncast.stream.current() retorna
given.broadcasterO que owncast.stream.broadcaster() retorna
given.serverO que owncast.server.info() retorna
given.socialsO que owncast.server.socials() retorna
given.federationO que owncast.server.federation() retorna
given.tagsO que owncast.server.tags() retorna
given.videoConfigO que owncast.videoConfig.read() retorna
given.chatHistoryO que owncast.chat.history() retorna
given.chatClientsO que owncast.chat.clients() retorna
given.usersO que owncast.users.list() / .get(id) retorna
given.httpResponsesRespostas pré-definidas para chamadas de saída owncast.http.fetch

Exemplo:

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

Respostas HTTP pré-definidas

Para plugins que chamam owncast.http.fetch, given.httpResponses é um array de respostas pré-definidas. Cada fixture é um objeto plano: url (um glob, por exemplo https://api.foo.com/*), method opcional, status, headers opcionais, e body.

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

Uma fixture corresponde por glob de url (e por method, se definido). 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. Se seu plugin fizer uma chamada que nenhuma fixture corresponda, o framework falha o cenário para que você saiba adicionar um caso.

Autenticação em cenários HTTP

Por padrão, etapas HTTP são tratadas como não autenticadas. Para exercitar endpoints de admin, defina authenticated: true:

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

Para endpoints com chat-user-token, defina user:

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

Sem nenhuma das flags, requisições para caminhos administrativos declarados no manifest retornam 401 antes do código do seu plugin ser executado. Útil para asserir que o gate de autenticação funciona:

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

Velocidade e isolamento

  • Cada cenário recebe uma nova instância do plugin e uma configuração limpa em memória. O estado não vaza de um cenário para outro.
  • Os testes são rápidos. Um arquivo de teste típico com reconstrução termina em alguns segundos. Execute-os a cada salvamento.
  • Nenhum Owncast real necessário. O runtime vem empacotado com o SDK, então você não precisa de um servidor para testar.

Servidor de desenvolvimento local

Para iteração interativa, execute um servidor de desenvolvimento local que carregue seu plugin e o sirva em http://localhost:8080/plugins/\<your-slug>/: faça curl nos seus endpoints, abra páginas estáticas no navegador ou acione seus manipuladores de eventos e filtros.

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

Além de arquivos estáticos e suas rotas HTTP, ele expõe endpoints apenas para desenvolvimento para acionar os handlers que um servidor HTTP comum não consegue alcançar. Leituras do host (informações do servidor, configuração de vídeo, etc.) retornam dados de desenvolvimento de exemplo.

  • POST /_dev/chat com {"user":"alice","body":"hi"}: executa sua cadeia de filtros de chat-message, então dispara chat.message.received. A resposta JSON mostra o que seu filtro fez.
  • GET /_dev/chat: o log de chat até agora, incluindo qualquer coisa que seu plugin tenha postado.
  • POST /_dev/event com {"type":"stream.started","payload":{}}: despacha um evento arbitrário para seus handlers.

Reinicie o servidor de desenvolvimento quando você mudar seu código. Use testes de cenário para asserções repetíveis. O servidor de desenvolvimento é para iteração interativa. Muitos autores executam ambos: servidor de desenvolvimento em um terminal, observador de testes em outro.


Improve this page

See something missing or incorrect? Edit this page and improve the documentation for everyone.

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