Vai al contenuto principale

Test dei plugin

I plugin di Owncast includono un framework di test basato su scenari che esegue il tuo plugin compilato nel runtime reale dei plugin di Owncast, con gli effetti collaterali (chat sends, HTTP fetches, config writes) catturati per le asserzioni. Un test che passa significa lo stesso comportamento in produzione.

Plugin testing requires Owncast v0.3.0

Plugins require Owncast 0.3.0 or later.

Gli scenari sono semplici dati, quindi il modello di scenario in questa pagina è identico indipendentemente dal linguaggio in cui scrivi. I file di test si trovano sotto __tests__/. Il modo in cui li scrivi ed esegui differisce leggermente a seconda dell'SDK.

Scrivere ed eseguire i test

Scrivi file __tests__/*.test.js che chiamano 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'] },
},
]);

Eseguili con npm test. Poiché è uno script, puoi costruire l'array di scenari con loop, fixture e payload calcolati. Distribuisci gli scenari su diversi file __tests__/*.test.js ed eseguili tutti in un'unica passata con runScenarioFiles(). Anche i file statici __tests__/*.test.json funzionano.

L'esecuzione dei test compila il tuo plugin, poi esegue tutti i file di scenario sotto __tests__/. Il modello di dati degli scenari è lo stesso indipendentemente dall'SDK che usi.

Owncat saysI nomi dei campi sulla wire restano in camelCase

Uno scenario descrive eventi dell'host, non il codice del tuo plugin, quindi i campi del payload usano i nomi on-the-wire (displayName, clientId) indipendentemente dal linguaggio in cui hai scritto il plugin.

Anatomia di uno scenario

{
"name": "human-readable description",
"given": {},
"events": [],
"expect": {}
}
  • name: cosa testa lo scenario. Mostrato nell'output di pass/fail.
  • given: opzionale. Inizializza lo stato iniziale che il tuo plugin legge (cronologia della chat, valori kv, informazioni del server, risposte HTTP preconfezionate).
  • events: i passaggi da eseguire, in ordine. Ogni passaggio è l'invio di una notifica, l'invocazione di una catena di filtri o una richiesta HTTP.
  • expect: asserzioni sullo stato finale (dopo l'esecuzione di tutti i passaggi). Quali messaggi di chat sono stati inviati, quali richieste HTTP sono state effettuate, cosa è stato scritto in kv, e così via.

Tipi di passaggi

event: notifica fire-and-forget

Invia una notifica al gestore dell'evento corrispondente. 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"
}
}

Tipi di evento comuni includono chat.message.received, chat.user.joined, stream.started e stream.stopped. Gli scenari Fediverse possono inviare fediverse.follow, fediverse.like, fediverse.repost, fediverse.quote, fediverse.mention, fediverse.reply, o il generico fediverse.activity. L'elenco completo rispecchia il riferimento ai gestori.

filter: invocazione della catena con asserzione inline

Invia un messaggio nella chat al tuo filtro chat-message e verifica il risultato. L'expect qui è per passaggio, asserendo su FilterResult:

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

O per asserire uno drop:

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

action è uno di "pass", "modify", "drop".

http: invia una richiesta HTTP tramite il tuo plugin

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

Header e body sono opzionali:

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

authCheck: ri-valida una sessione gate

Per i plugin auth.gate, esegue direttamente l'handler onAuthCheck con un'identità del viewer risolta e asserisce il verdetto:

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

Passaggi di contenuto

tabContent, pageContent, pageStyles e pageScripts chiamano direttamente il gestore di contenuti corrispondente e verificano il markup, il CSS o il JavaScript restituiti:

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

Asserzioni sullo stato finale

L'expect a livello superiore dello scenario verifica cosa è successo durante tutta l'esecuzione:

AsserzioneCosa verifica
chatSendsElenco di stringhe owncast.chat.send (corrispondenza esatta, in ordine)
chatActionsElenco di stringhe owncast.chat.sendAction
chatSystemsElenco di stringhe 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
chatToElenco di { clientId, text } da owncast.chat.sendTo / replyTo
sseSendsOrdered list of { channel, event?, data? } from owncast.sse.send (omit event/data to match only on channel)
deletedMessagesID dei messaggi nascosti tramite owncast.chat.deleteMessage
kickedClientsID dei client disconnessi tramite owncast.chat.kick
discordPostsElenco di stringhe di notifiche Discord
browserPushesElenco di payload per push del browser { 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
userModerationsElenco di { userId, enabled, reason } da owncast.users.setEnabled
bannedIPsElenco di IP bannati tramite 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
videoConfigWritesElenco di configurazioni parziali applicate tramite 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)
kvMappa parziale dello stato della configurazione del plugin dopo lo scenario
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 le altre asserzioni sulla chat) catturano post da qualsiasi passaggio: inclusi i messaggi che il tuo plugin invia dall'interno di un handler di richieste HTTP, non solo dagli handler di eventi.

owncast.fs.* (la sandbox storage.fs) non ha un'asserzione dedicata: il runtime la supporta con una vera sandbox in memoria durante i test, quindi testala nel modo in cui la useresti: usa gli endpoint (o gli handler) del tuo plugin e asserisci su ciò che restituiscono. Ad esempio, POST di un file tramite il tuo endpoint di upload, poi GET del tuo endpoint di elenco e verifica che la risposta lo includa. L'esempio file-manager fa esattamente questo.

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.

Esempio che esercita vari aspetti:

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

Inizializzazione dello stato con given

Ogni campo given.* controlla cosa restituisce una specifica lettura dell'host. Combinali per mettere il tuo plugin in qualsiasi stato desideri.

CampoControlla
given.kvPrecompila lo store key/value del tuo plugin (owncast.kv)
given.configAdmin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults
given.streamCosa restituisce owncast.stream.current()
given.broadcasterCosa restituisce owncast.stream.broadcaster()
given.serverCosa restituisce owncast.server.info()
given.socialsCosa restituisce owncast.server.socials()
given.federationCosa restituisce owncast.server.federation()
given.tagsCosa restituisce owncast.server.tags()
given.videoConfigCosa restituisce owncast.videoConfig.read()
given.chatHistoryCosa restituisce owncast.chat.history()
given.chatClientsCosa restituisce owncast.chat.clients()
given.usersCosa restituiscono owncast.users.list() / .get(id)
given.httpResponsesRisposte preconfezionate per chiamate in uscita owncast.http.fetch

Esempio:

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

Risposte HTTP preconfezionate

Per i plugin che chiamano owncast.http.fetch, given.httpResponses è un array di risposte preconfezionate. Ogni fixture è un oggetto piatto: url (un glob, p.es. https://api.foo.com/*), method opzionale, status, headers opzionali e body.

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

Una fixture corrisponde per glob url (e method, se impostato). 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 il tuo plugin effettua una chiamata per la quale nessuna fixture corrisponde, il framework fallisce lo scenario così sai di dover aggiungere un caso.

Autenticazione negli scenari HTTP

Per impostazione predefinita, i passaggi HTTP sono considerati non autenticati. Per esercitare gli endpoint admin, imposta authenticated: true:

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

Per gli endpoint chat-user-token, imposta user:

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

Senza nessuno dei due flag, le richieste verso percorsi admin dichiarati nel manifesto restituiscono 401 prima che il codice del tuo plugin venga eseguito. Utile per verificare che il gate di autenticazione funzioni:

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

Velocità e isolamento

  • Ogni scenario riceve una nuova istanza del plugin e una configurazione pulita in memoria. Lo stato non trapela da uno scenario al successivo.
  • I test sono veloci. Un tipico file di test con ricompilazione termina in pochi secondi. Eseguili ad ogni salvataggio.
  • Non è richiesto un Owncast reale. Il runtime è incluso nell'SDK, quindi non hai bisogno di un server per testare.

Server di sviluppo locale

Per iterazioni interattive, esegui un server di sviluppo locale che carica il tuo plugin e lo serve a http://localhost:8080/plugins/\<your-slug>/: fai curl ai tuoi endpoint, apri pagine statiche in un browser, o guida i tuoi handler di eventi e filtri.

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

Oltre ai file statici e alle tue route HTTP, espone endpoint solo per sviluppo per azionare gli handler che un semplice server HTTP non può raggiungere. Le letture dell'host (info del server, configurazione video, e così via) restituiscono dati di esempio per sviluppo.

  • POST /_dev/chat con {"user":"alice","body":"hi"}: esegue la tua catena di filtri chat-message, poi genera chat.message.received. La risposta JSON mostra cosa ha fatto il tuo filtro.
  • GET /_dev/chat: il log della chat fino a quel momento, incluso qualsiasi contenuto inviato dal tuo plugin.
  • POST /_dev/event con {"type":"stream.started","payload":{}}: invia un evento arbitrario ai tuoi handler.

Riavvia il server di sviluppo quando modifichi il codice. Usa i test di scenario per asserzioni ripetibili. Il server di sviluppo è per l'iterazione interattiva. Molti autori eseguono entrambi: il server di sviluppo in un terminale, il watcher dei test in un altro.


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