メインコンテンツへスキップ

プラグインのテスト

Owncast プラグインには、ビルド済みプラグインを実際の Owncast プラグインランタイムで動かし、(チャット送信、HTTP フェッチ、設定書き込みなどの)副作用をキャプチャしてアサーションできるシナリオベースのテストフレームワークが同梱されています。 テストが通るということは、本番環境でも同じ動作をすることを意味します。

Plugin testing requires Owncast v0.3.0

Plugins require Owncast 0.3.0 or later.

シナリオはプレーンデータなので、このページのシナリオモデルは、プラグインをどの言語で書いても同一です。 テストファイルは __tests__/ 配下に置きます。 書き方や実行方法は SDK によって若干異なります。

テストの作成と実行

__tests__/*.test.js ファイルを書き、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'] },
},
]);

npm test で実行します。 スクリプトなので、ループやフィクスチャ、計算されたペイロードでシナリオ配列を組み立てられます。 シナリオを複数の __tests__/*.test.js ファイルに分割し、runScenarioFiles() でまとめて一度に実行します。 静的な __tests__/*.test.json ファイルも使えます。

テスト実行はプラグインをビルドし、その後 __tests__/ 配下のすべてのシナリオファイルを実行します。 シナリオのデータモデルは、どの SDK を使っても同じです。

Owncat saysワイヤーフィールド名は camelCase のまま

シナリオはあなたのプラグインのコードではなくホストイベントを記述するので、ペイロードのフィールドは、プラグインをどの言語で書いたかに関係なくワイヤー名(displayName, clientId)を使います。

シナリオの構成

{
"name": "human-readable description",
"given": {},
"events": [],
"expect": {}
}
  • name: シナリオがテストする内容。 合否出力に表示されます。
  • given: 任意です。 プラグインが読み取る初期状態をシードします(チャット履歴、kv 値、サーバ情報、定義済みの HTTP レスポンス)。
  • events: 順番に実行するステップです。 各ステップは通知のディスパッチ、フィルターチェーンの呼び出し、または HTTP リクエストのいずれかです。
  • expect: 最終状態のアサーション(すべてのステップ実行後)。 どのチャットメッセージが投稿されたか、どの HTTP リクエストが送信されたか、kv に何が書き込まれたか、など。

ステップの種類

event: fire-and-forget 通知

一致するイベントハンドラに通知をディスパッチします。 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"
}
}

よくあるイベントタイプには chat.message.receivedchat.user.joinedstream.startedstream.stopped などがあります。 Fediverse のシナリオは fediverse.followfediverse.likefediverse.repostfediverse.quotefediverse.mentionfediverse.reply、または汎用の fediverse.activity をディスパッチできます。 完全な一覧は ハンドラ参照 と対応しています。

filter: インラインアサーションを伴うチェーンの呼び出し

チャットメッセージをチャットメッセージフィルタに送り、結果を検証します。 ここでの expect はステップごとで、FilterResult に対してアサートします:

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

またはドロップをアサートするには:

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

action"pass""modify""drop" のいずれかです。

http: プラグイン経由で HTTP リクエストを送信する

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

ヘッダーとボディは省略可能です:

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

authCheck: ゲートセッションを再検証する

auth.gate プラグインでは、解決されたビューワ識別を使ってonAuthCheck ハンドラを直接呼び出し、判定をアサートします:

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

コンテンツステップ

tabContentpageContentpageStylespageScripts は該当するコンテンツハンドラを直接呼び出し、返されたマークアップ、CSS、または JavaScript に対してアサートします:

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

最終状態のアサーション

シナリオのトップレベルの expect は、実行全体で何が起きたかをチェックします:

アサーションチェックする内容
chatSendsowncast.chat.send 文字列の一覧(完全一致、順序通り)
chatActionsowncast.chat.sendAction 文字列の一覧
chatSystemsowncast.chat.system 文字列の一覧
logsOrdered list of { plugin, level, message } entries from owncast.log. plugin is the manifest slug and level is info, warning, or error
chatToowncast.chat.sendTo / replyTo からの { clientId, text } の一覧
sseSends順序付きの { channel, event?, data? } from owncast.sse.send (omit event/data to match only on channel)
deletedMessagesowncast.chat.deleteMessage によって非表示にされたメッセージ ID
kickedClientsowncast.chat.kick により切断されたクライアント ID
discordPostsDiscord 通知文字列の一覧
browserPushesブラウザプッシュの { 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
userModerationsowncast.users.setEnabled からの { userId, enabled, reason } の一覧
bannedIPsowncast.users.banIP により禁止された IP の一覧
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
videoConfigWritesowncast.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)
kvシナリオ後のプラグイン設定状態の部分マップ
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(および他のチャット系アサーション)は 任意の ステップからの投稿をキャプチャします:HTTP リクエストハンドラ内からプラグインが送信したチャットも含まれ、イベントハンドラだけではありません。

owncast.fs.*storage.fs サンドボックス)には専用のアサーションはありません:ランタイムはテスト中に実際のインメモリサンドボックスでこれをサポートするので、使用する方法でテストしてください:プラグインのエンドポイント(またはハンドラ)を駆動し、それらが返すものをアサートします。 例えば、アップロードエンドポイントに対して POST でファイルを送信し、次に一覧エンドポイントを GET してレスポンスにそのファイルが含まれていることをアサートします。 この目的は file-manager の例でまさに実演されています。

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.

いくつかを実演する例:

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

given による状態のシード

given.* フィールドは、特定のホスト読み取りが返す内容を制御します。 これらを組み合わせて、プラグインを任意の状態に設定できます。

フィールド制御内容
given.kvプラグインのキー/バリューストア(owncast.kv)を事前に設定する
given.configAdmin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults
given.streamowncast.stream.current() が返すもの
given.broadcasterowncast.stream.broadcaster() が返すもの
given.serverowncast.server.info() が返すもの
given.socialsowncast.server.socials() が返すもの
given.federationowncast.server.federation() が返すもの
given.tagsowncast.server.tags() が返すもの
given.videoConfigowncast.videoConfig.read() が返すもの
given.chatHistoryowncast.chat.history() が返すもの
given.chatClientsowncast.chat.clients() が返すもの
given.usersowncast.users.list() / .get(id) が返すもの
given.httpResponsesアウトバウンドの owncast.http.fetch 呼び出しに対する定型レスポンス

例:

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

定型 HTTP レスポンス

owncast.http.fetch を呼ぶプラグインの場合、given.httpResponses は定型レスポンスの配列です。 各フィクスチャはフラットなオブジェクトです:url(グロブ、例:https://api.foo.com/*)、オプションの methodstatus、オプションの headers、および body

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

フィクスチャは url のグロブ(および設定されていれば method)でマッチします。 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. プラグインがフィクスチャにマッチしない呼び出しを行った場合、フレームワークはシナリオを失敗させ、ケースを追加する必要があることを知らせます。

HTTP シナリオにおける認証

デフォルトでは、HTTP ステップは認証されていないものとして扱われます。 管理者用エンドポイントをテストするには、authenticated: true を設定します:

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

チャットユーザートークン用のエンドポイントでは、user を設定します:

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

どちらのフラグもない場合、マニフェストで宣言された管理パスへのリクエストはプラグインコードが実行される前に 401 を返します。 認証ゲートが機能することをアサートするのに便利です:

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

速度と隔離

  • 各シナリオは新しいプラグインインスタンスとクリーンなインメモリ設定を得ます。 状態はシナリオ間で漏れません。
  • テストは高速です。 再ビルドを伴う典型的なテストファイルは数秒で終了します。 保存のたびに実行してください。
  • 実際の Owncast は不要です。 ランタイムは SDK にバンドルされているため、テストにサーバは必要ありません。

ローカル開発サーバ

対話的な反復作業には、プラグインを読み込み http://localhost:8080/plugins/\<your-slug>/ で提供するローカル開発サーバを起動します:エンドポイントに curl を打ったり、ブラウザで静的ページを開いたり、イベント・フィルタハンドラを駆動したりできます。

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

静的ファイルや HTTP ルートに加えて、通常の HTTP サーバからは到達できないハンドラを駆動するための開発専用エンドポイントも公開します。 ホスト読み取り(サーバ情報、ビデオ設定など)はサンプルの開発用データを返します。

  • POST /_dev/chat{"user":"alice","body":"hi"} を送ると:チャットメッセージのフィルタチェーンを実行し、その後 chat.message.received を発火します。 JSON レスポンスはフィルタが行った処理を示します。
  • GET /_dev/chat: これまでのチャットログ(プラグインが投稿したものも含む)です。
  • POST /_dev/event{"type":"stream.started","payload":{}} を送ると:任意のイベントをハンドラにディスパッチします。

コードを変更したら開発サーバを再起動してください。 繰り返し可能なアサーションにはシナリオテストを使用してください。 開発サーバは対話的な反復作業用です。 多くの作者は両方を並行して実行します。片方のターミナルで開発サーバ、もう片方でテストウォッチャーを実行します。


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