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

Тестирование: харнессы

Здесь описаны четыре тестовых харнесса, которые работают без запущенного bb: createFakePluginHost (бэкенд), renderSlot + loadPluginApp (фронтенд), createFakeSdk и experimental_createHostEntryHarness (хостовая точка входа). Ниже — чем управляет и что показывает каждый харнесс, а также где заканчивается их достоверность.

Этот харнесс нужен автору серверной точки входа в первую очередь. Он отдаёт настоящий объект bb и пульт управления им, соблюдая те же правила, что и продакшен. Например, он отказывается регистрировать провайдер или ИИ-сервис для плагина без bb.host и не даёт использовать глиф с неймспейсом, которого нет в манифесте.

import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import plugin from "./server";
const { bb, harness } = createFakePluginHost({
pluginId: "hello", // defaults to "test-plugin"
settings: { showDone: false }, // as if saved before this load, secrets included
sdk: { threads: { list: async () => [] } }, // bb.sdk stubs; extend with harness.sdk.stub(...)
experimental_hostEntry: true, // whether the manifest declares bb.host; default true
});
try {
await plugin(bb); // an ordinary call of your factory
// drive the surfaces "as the host would"
await expect(harness.behavior.callRpc("todos_add", { title: "Ship it" }))
.resolves.toMatchObject({ title: "Ship it" });
const cli = await harness.behavior.runCli(["list"]);
expect(cli.exitCode).toBe(0);
// and read back what the plugin did
expect(harness.registrations.rpcMethods).toContain("todos_add");
expect(harness.realtimeSignals.at(-1)?.channel).toBe("todos-changed");
expect(harness.logEntries[0]).toMatchObject({ level: "info", message: "loaded" });
} finally {
await harness.lifecycle.dispose(); // runs onDispose and clears the temporary storage
}

callRpc(method, input?) · runCli(argv, ctx?) · fetchHttp(...) · experimental_openWebSocket(...) · runService(name) · runSchedule(name) · callAgentTool(...) · resolveAgentConfiguration(context) · resolveProviderEnv(...) / resolveProviderEnvHealth(...) · setSettings(values) · submitInteraction(id, value) / cancelInteraction(id) · experimental_emitHostSignal(...) / experimental_emitHostWorkerExit(hostId).

harness.lifecycle предоставляет reload(factory) и dispose(). Сам harness наследует inspection, behavior и lifecycle, поэтому harness.callRpc(...) и harness.behavior.callRpc(...) — одно и то же.

pluginId · logEntries · realtimeSignals · needsConfigurationMessages · recheckCount · sdk (FakeSdkHarness) · registrations (маршруты, RPC-методы, расписания, сервисы, CLI, инструменты, провайдеры упоминаний и experimental_publishedRpcMethods с описаниями и JSON-схемами обнаруживаемых методов) · sharedPortDeclarations · experimental_hostRpcCalls · pendingInteractions.

Осталось ещё два харнесса. createFakeSdk({ pluginId, overrides }) — это отдельный записывающий дублёр bb.sdk. Он фиксирует вызовы после серверной нормализации. Если вызвать метод без стаба, харнесс бросит исключение и укажет точный путь, по которому нужен стаб.

experimental_createHostEntryHarness(entry, options?) из @get-bb/plugin-sdk/testing/host запускает хостовую точку входа в текущем процессе с теми же границами, что и демон хоста (валидация, JSON-транспорт, отмена, жизненный цикл и лимит размера ответа). Харнесс даёт experimental_call, experimental_getSignals(), experimental_getRetainedWorkerLeaseCount(), experimental_lifecycleSignal и experimental_dispose(). За падения процесса по-прежнему отвечают интеграционные тесты демона.

// plugins/tasks/app.test.tsx (abridged) — a frontend slot driven by realtime
// @vitest-environment jsdom
import { cleanup, waitFor } from "@testing-library/react";
import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app";
const app = await loadPluginApp(() => import("./app")); // THE THUNK FORM ONLY!
afterEach(cleanup);
const Accessory = app.navPanels[0]?.experimental_sidebarAccessory;
const slot = renderSlot({ component: Accessory! }, {}, {
rpc: { sidebarOpenTaskCount: () => ({ openTaskCount }) },
});
expect(await slot.findByText("12")).toBeDefined();
await slot.behavior.emitRealtime("tasks:changed", { taskId: "…", projectId: "…" });
await waitFor(() => expect(slot.getByText("13")).toBeDefined());
expect(slot.inspection.rpcCalls.filter(({ method }) => method === "sidebarOpenTaskCount"))
.toHaveLength(3);
  • installTestPluginRuntime() заполняет globalThis.__bbPluginRuntime.pluginSdkApp. Запустите его до выполнения app.tsx, потому что этот модуль привязывает рантайм при импорте.
  • loadPluginApp(source) устанавливает рантайм, резолвит определение, проверяет метку __bbPluginApp и запускает тот же валидирующий сборщик с теми же текстами ошибок, что и хост. Передайте thunk () => import("./app"), чтобы модуль плагина выполнился после установщика.
  • renderSlot(registration, props, options?) принимает только { component }, поэтому работает с любым компонентом из регистрации, включая navPanels[0].experimental_sidebarAccessory.

RenderSlotOptions принимает: rpc (ввод и результаты проходят через строгий JSON, словно идут по сети; метод без обработчика отклоняется с ошибкой «no rpc handler for …»), settings, context, realtimeConnectionState, composer, sidebarThreads, providers, codeTheme, branchesState, checkoutState, sidebarPullRequests, openThreadPanel, openUrl, openFilePreview, openFileExternally, experimental_openFixedTab, experimental_fixedTabTarget.

  • behavior: emitRealtime (обёрнут в act, а полезная нагрузка проходит через JSON точно так же, как при вызове bb.realtime.publish), setRealtimeConnectionState, setComposerText, setComposerScope.
  • inspection: rpcCalls, navigateCalls (размеченное объединение для всех девяти методов BbNavigate), experimental_fixedTabOpenCalls, sidebarActionCalls, composer (текст, область видимости, вложения, эффекты, блокировки, цитаты, упоминания, фокусы, отправки, выделения).
  • lifecycle: rerender(ui), unmount().
  • Харнесс для content script: mountPluginContentScripts(app, { pluginId, generation?, omitExperimentalThreadRowStatus? }). Последний флаг симулирует старый хост без API статуса строк. Порядок монтирования точно такой же, как на хосте: если при монтировании возникает исключение, харнесс демонтирует уже смонтированные скрипты в обратном порядке и пробрасывает ошибку дальше.