Тестирование: харнессы
Здесь описаны четыре тестовых харнесса, которые работают без запущенного bb: createFakePluginHost (бэкенд), renderSlot + loadPluginApp (фронтенд), createFakeSdk и experimental_createHostEntryHarness (хостовая точка входа). Ниже — чем управляет и что показывает каждый харнесс, а также где заканчивается их достоверность.
Бэкенд: createFakePluginHost
Заголовок раздела «Бэкенд: createFakePluginHost»Этот харнесс нужен автору серверной точки входа в первую очередь. Он отдаёт настоящий объект 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}Что умеет harness.behavior
Заголовок раздела «Что умеет harness.behavior»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(...) — одно и то же.
Что показывает inspection
Заголовок раздела «Что показывает inspection»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(). За падения процесса по-прежнему отвечают интеграционные тесты демона.
Фронтенд: loadPluginApp и renderSlot
Заголовок раздела «Фронтенд: loadPluginApp и renderSlot»// plugins/tasks/app.test.tsx (abridged) — a frontend slot driven by realtime// @vitest-environment jsdomimport { 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 статуса строк. Порядок монтирования точно такой же, как на хосте: если при монтировании возникает исключение, харнесс демонтирует уже смонтированные скрипты в обратном порядке и пробрасывает ошибку дальше.