Testing: the harnesses
This page covers four test harnesses, each of which runs without a running bb: createFakePluginHost (backend), renderSlot + loadPluginApp (frontend), createFakeSdk, and experimental_createHostEntryHarness (host entry). It shows what each harness drives and exposes, and where the harnesses stop being faithful.
Backend: createFakePluginHost
Section titled “Backend: createFakePluginHost”The harness a server entry author needs first: it hands you a real bb object and a remote control for it, applying the same rules production does, including the refusal to register a provider or an AI service for a plugin without bb.host, and the refusal of a namespaced glyph the manifest never declared.
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}What harness.behavior can do
Section titled “What harness.behavior can do”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 gives reload(factory) and dispose(); harness itself inherits inspection, behavior and lifecycle, so harness.callRpc(...) and harness.behavior.callRpc(...) are the same thing.
What inspection exposes
Section titled “What inspection exposes”pluginId · logEntries · realtimeSignals · needsConfigurationMessages · recheckCount · sdk (a FakeSdkHarness) · registrations (routes, RPC methods, schedules, services, CLI, tools, mention providers, and experimental_publishedRpcMethods with the descriptions and JSON schemas of discoverable methods) · sharedPortDeclarations · experimental_hostRpcCalls · pendingInteractions.
The two remaining harnesses. createFakeSdk({ pluginId, overrides }) is a separate recording double of bb.sdk: calls are recorded after the normalization the server applies, and a call with no stub throws naming the exact path to stub.
experimental_createHostEntryHarness(entry, options?) from @get-bb/plugin-sdk/testing/host runs a host entry in-process through the same boundaries the daemon uses (validation, the JSON transport, cancellation, the lifecycle and the response size ceiling) and gives you experimental_call, experimental_getSignals(), experimental_getRetainedWorkerLeaseCount(), experimental_lifecycleSignal and experimental_dispose(). Process crashes remain the business of the daemon’s integration tests.
Frontend: loadPluginApp and renderSlot
Section titled “Frontend: loadPluginApp and 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);The three steps of the frontend harness
Section titled “The three steps of the frontend harness”installTestPluginRuntime()fillsglobalThis.__bbPluginRuntime.pluginSdkApp. It must run beforeapp.tsxis evaluated, because that module binds the runtime on import.loadPluginApp(source)installs the runtime, resolves the definition, checks the__bbPluginAppbrand and runs the same validating collector the host runs, with the same error texts. Pass the thunk() => import("./app")so the plugin module is evaluated after the installer.renderSlot(registration, props, options?)takes only{ component }, so any component reachable from a registration works, includingnavPanels[0].experimental_sidebarAccessory.
RenderSlotOptions: rpc (input and results travel through strict JSON, as they would over the wire; a method with no handler rejects with “no rpc handler for …”), settings, context, realtimeConnectionState, composer, sidebarThreads, providers, codeTheme, branchesState, checkoutState, sidebarPullRequests, openThreadPanel, openUrl, openFilePreview, openFileExternally, experimental_openFixedTab, experimental_fixedTabTarget.
What the harness hands back
Section titled “What the harness hands back”behavior:emitRealtime(wrapped inact, with the payload passed through JSON exactly asbb.realtime.publishdoes),setRealtimeConnectionState,setComposerText,setComposerScope.inspection:rpcCalls,navigateCalls(a discriminated union over all nineBbNavigatemethods),experimental_fixedTabOpenCalls,sidebarActionCalls,composer(text, scope, attachments, effects, locks, quotes, mentions, focuses, submits, selections).lifecycle:rerender(ui),unmount().- Content scripts get their own harness,
mountPluginContentScripts(app, { pluginId, generation?, omitExperimentalThreadRowStatus? }); the last flag simulates an older host with no row-status API. Mount order is host-exact: a mount that throws unwinds the already-mounted ones in reverse and rethrows.