Перейти к основному содержимому

Тестирование плагинов

Плагины 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: уведомление без ожидания ответа

Отправляет уведомление соответствующему обработчику событий. 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.received, chat.user.joined, stream.started и stream.stopped. Сценарии Fediverse могут отправлять fediverse.follow, fediverse.like, fediverse.repost, fediverse.quote, fediverse.mention, fediverse.reply или общий fediverse.activity. Полный список совпадает со справочником handlers reference.

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: повторная валидация сессии 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.

Шаги контента

tabContent, pageContent, pageStyles и pageScripts вызывают соответствующий обработчик контента напрямую и проверяют возвращённую разметку, 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 (точное совпадение, в порядке)
chatActionsСписок строк owncast.chat.sendAction
chatSystemsСписок строк 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
chatToСписок { clientId, text } от owncast.chat.sendTo / replyTo
sseSendsOrdered list of { channel, event?, data? } from owncast.sse.send (omit event/data to match only on channel)
deletedMessagesИдентификаторы сообщений, скрытые с помощью owncast.chat.deleteMessage
kickedClientsИдентификаторы клиентов, отключённых с помощью owncast.chat.kick
discordPostsСписок строк уведомлений Discord
browserPushesСписок полезных нагрузок для push в браузере { 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Список { userId, enabled, reason } от owncast.users.setEnabled
bannedIPsСписок IP-адресов, заблокированных через 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
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Предзаполните key/value хранилище вашего плагина (owncast.kv)
given.configAdmin-set overrides for manifest-declared config keys (owncast.config.get). Unseeded keys return the manifest defaults
given.streamЧто возвращает owncast.stream.current()
given.broadcasterЧто возвращает owncast.stream.broadcaster()
given.serverЧто возвращает owncast.server.info()
given.socialsЧто возвращает owncast.server.socials()
given.federationЧто возвращает owncast.server.federation()
given.tagsЧто возвращает owncast.server.tags()
given.videoConfigЧто возвращает owncast.videoConfig.read()
given.chatHistoryЧто возвращает owncast.chat.history()
given.chatClientsЧто возвращает owncast.chat.clients()
given.usersЧто возвращают owncast.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/*), необязательное method, status, необязательные headers и body.

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

Фикстура совпадает по глобу urlmethod, если задан). 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