跳至主要内容

测试插件

Owncast 插件随附一个基于场景的测试框架,它会在真实的 Owncast 插件运行时中驱动你已构建的插件,并将副作用(聊天发送、HTTP 获取、配置写入)捕获以便断言。 测试通过意味着在生产环境中具有相同的行为。

Plugin testing requires Owncast v0.3.0

Plugins require Owncast 0.3.0 or later.

场景是纯数据,因此本页的场景模型在任何编程语言中都是相同的。 测试文件位于 __tests__/ 下。 编写和运行它们的方式会因 SDK 而略有不同。

编写与运行测试

编写调用 runScenarios([...])__tests__/*.test.js 文件:

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 运行它们。 因为这是脚本,你可以用循环、fixtures 和计算生成的有效载荷来构建场景数组。 将场景拆分到多个 __tests__/*.test.js 文件中,并使用 runScenarioFiles() 在一次运行中执行它们。 静态的 __tests__/*.test.json 文件也可以。

运行测试会先构建你的插件,然后执行 __tests__/ 下的每个场景文件。 无论使用哪个 SDK,场景数据模型都是相同的。

Owncat says传输字段名保持为 camelCase

场景描述的是 主机事件,而不是你的插件代码,因此 payload 字段使用传输名称(displayNameclientId),与插件所用语言无关。

场景结构

{
"name": "human-readable description",
"given": {},
"events": [],
"expect": {}
}
  • name: 场景测试的内容。 显示在通过/失败输出中。
  • given: 可选。 种子化插件读取的初始状态(聊天历史、kv 值、服务器信息、预设的 HTTP 响应)。
  • events: 要按顺序执行的步骤。 每个步骤是一次通知分发、过滤器链调用或 HTTP 请求。
  • expect: 最终状态断言(在每个步骤运行后)。 哪些聊天消息被发送、发出了哪些 HTTP 请求、写入了 kv 的内容,等等。

步骤类型

event: 一次性通知

将通知派发给匹配的事件处理器。 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 通用事件。 完整列表参见 handlers reference

filter: 带内联断言的链式调用

向你的 chat-message 过滤器发送一条聊天消息并检查结果。 这里的 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: 重新验证 gate 会话

对于 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 检查整个运行过程中发生的情况:

断言检查的内容
chatSends按顺序的 owncast.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
chatTo来自 owncast.chat.sendTo / replyTo{ clientId, text } 列表
sseSendsOrdered list of { channel, event?, data? } from owncast.sse.send (omit event/data to match only on channel)
deletedMessages通过 owncast.chat.deleteMessage 隐藏的消息 ID
kickedClients通过 owncast.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
userModerations来自 owncast.users.setEnabled{ userId, enabled, reason } 列表
bannedIPs通过 owncast.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
videoConfigWrites通过 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)
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 是一个预设响应数组。 每个 fixture 是一个扁平对象:url(一个通配符,例如 https://api.foo.com/*)、可选的 methodstatus、可选的 headersbody

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

一个 fixture 通过 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. 如果你的插件发起的调用没有任何 fixture 匹配,框架会使场景失败,以便你知道需要添加相应的用例。

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 过滤器链,然后触发 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