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.
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
- JavaScript
- Python
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.
Escreva arquivos __tests__/*.test.json contendo um array de cenários (o formato mostrado ao longo desta página):
[
{
"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 owncast-plugin-py test (o argumento de diretório padrão é o atual). O Python usa o formato JSON: não há um executor baseado em script.
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.
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ção | O que verifica |
|---|---|
chatSends | Lista de strings owncast.chat.send (correspondência exata, em ordem) |
chatActions | Lista de strings owncast.chat.sendAction |
chatSystems | Lista de strings owncast.chat.system |
logs | Ordered list of { plugin, level, message } entries from owncast.log. plugin is the manifest slug and level is info, warning, or error |
chatTo | List of { clientId, text } from owncast.chat.sendTo / replyTo |
sseSends | Ordered list of { channel, event?, data? } from owncast.sse.send (omit event/data to match only on channel) |
deletedMessages | IDs de mensagens ocultados via owncast.chat.deleteMessage |
kickedClients | IDs de cliente desconectados via owncast.chat.kick |
discordPosts | Lista de strings de notificações do Discord |
browserPushes | Lista de { title, body, url } payloads de push do navegador |
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 | Lista de { userId, enabled, reason } de owncast.users.setEnabled |
bannedIPs | Lista de IPs banidos via owncast.users.banIP |
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 | Lista de configurações parciais aplicadas via owncast.videoConfig.write() |
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 | Mapa parcial do estado de configuração do plugin após o cenário |
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 (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.
| Campo | Controla |
|---|---|
given.kv | Pré-popule o key/value store do seu plugin (owncast.kv) |
given.config | Admin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults |
given.stream | O que owncast.stream.current() retorna |
given.broadcaster | O que owncast.stream.broadcaster() retorna |
given.server | O que owncast.server.info() retorna |
given.socials | O que owncast.server.socials() retorna |
given.federation | O que owncast.server.federation() retorna |
given.tags | O que owncast.server.tags() retorna |
given.videoConfig | O que owncast.videoConfig.read() retorna |
given.chatHistory | O que owncast.chat.history() retorna |
given.chatClients | O que owncast.chat.clients() retorna |
given.users | O que owncast.users.list() / .get(id) retorna |
given.httpResponses | Respostas 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.
- 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
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/chatcom{"user":"alice","body":"hi"}: executa sua cadeia de filtros de chat-message, então disparachat.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/eventcom{"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.
