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.
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
- JavaScript
- Python
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.
Scrivi file __tests__/*.test.json contenenti un array di scenari (il formato mostrato in tutta questa pagina):
[
{
"name": "echoes the message",
"events": [
{
"event": "chat.message.received",
"payload": { "user": { "id": "u1", "displayName": "alice" }, "body": "hi" }
}
],
"expect": { "chatSends": ["alice said: hi"] }
}
]
Eseguili con owncast-plugin-py test (l'argomento directory di default è quella corrente). Python usa il formato JSON: non c'è un runner basato su script.
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.
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:
| Asserzione | Cosa verifica |
|---|---|
chatSends | Elenco di stringhe owncast.chat.send (corrispondenza esatta, in ordine) |
chatActions | Elenco di stringhe owncast.chat.sendAction |
chatSystems | Elenco di stringhe 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 | Elenco di { clientId, text } da owncast.chat.sendTo / replyTo |
sseSends | Ordered list of { channel, event?, data? } from owncast.sse.send (omit event/data to match only on channel) |
deletedMessages | ID dei messaggi nascosti tramite owncast.chat.deleteMessage |
kickedClients | ID dei client disconnessi tramite owncast.chat.kick |
discordPosts | Elenco di stringhe di notifiche Discord |
browserPushes | Elenco di payload per push del browser { title, body, url } |
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 | Elenco di { userId, enabled, reason } da owncast.users.setEnabled |
bannedIPs | Elenco di IP bannati tramite 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 | Elenco di configurazioni parziali applicate tramite 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 | Mappa parziale dello stato della configurazione del plugin dopo lo scenario |
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 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.
| Campo | Controlla |
|---|---|
given.kv | Precompila lo store key/value del tuo plugin (owncast.kv) |
given.config | Admin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults |
given.stream | Cosa restituisce owncast.stream.current() |
given.broadcaster | Cosa restituisce owncast.stream.broadcaster() |
given.server | Cosa restituisce owncast.server.info() |
given.socials | Cosa restituisce owncast.server.socials() |
given.federation | Cosa restituisce owncast.server.federation() |
given.tags | Cosa restituisce owncast.server.tags() |
given.videoConfig | Cosa restituisce owncast.videoConfig.read() |
given.chatHistory | Cosa restituisce owncast.chat.history() |
given.chatClients | Cosa restituisce owncast.chat.clients() |
given.users | Cosa restituiscono owncast.users.list() / .get(id) |
given.httpResponses | Risposte 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.
- 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
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/chatcon{"user":"alice","body":"hi"}: esegue la tua catena di filtri chat-message, poi generachat.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/eventcon{"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.
