Testing
This page tests the complete counter plugin from the Introduction on every tier with @get-bb/plugin-sdk/testing. You write four files: a contracts test that proves the CLI, HTTP, tool, SDK, and docs derive; a server test that runs the real loader against in-memory storage; an app test that renders the footer button in the real UI kernel over a fake core; and a host test that calls a role handler without a process. At the end you know what the Stage 1 smoke proves that these harnesses cannot.
Use this when
- Unit-test a handler with its real schema. Defaults filled, bad input refused, the mutation reachable as a command and as a tool.
- Render a slot component in isolation. The owner props the real owner would pass, and a fake server service behind
useService. - Guard the derived surfaces in CI. The CLI word and the agent tool derive from
defineServicebefore anyone runsbb plugin build. - Prove a realtime refetch. An event on the socket refetches your
defineQuerywithout a reload. - Call a host command without a process. The machine tier's handler runs over a fake
RoleContext.
What you build
counter/
vitest.config.ts
src/contracts.test.ts # tier 4: expectDerivedSurfaces
src/server.test.ts # tier 1: createTestPlugin, inproc and worker
src/app.test.ts # tier 1: createAppHarness in happy-dom
src/host.test.ts # tier 1: fakeRoleContext (step 5 adds the role)The plugin under test is the complete counter from the Introduction (§2); page 01's version has no tool, event, or app tier yet, and three of these files fail against it. The assertions lean on these lines of the intro: bump declares expose: { tool: true } and a tool.presentation, so the tool counter_counter_bump exists (§2.2); changed is defineEvent({ name: "counter/changed", payload: valueSchema, … }), declared with ctx.realtime.declare(changed) and published as { value } on every bump (§2.2–2.3); STEP_KEY is "counter/step"; app.tsx registers CounterButton into sidebar.footer, whose owner passes { compact, closeOnMobile }, labels it count N, and bumps by usePreference(stepRef) (§2.4). Every harness is Node-only: the product never loads /testing, and a server build that imports it fails (D19).
Steps
1. Configure vitest
No environment in the config. Every file runs in Node; the app test installs happy-dom itself (step 4). Add happy-dom and vitest as dev dependencies.
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["src/**/*.test.ts"], testTimeout: 30_000, hookTimeout: 60_000 },
});2. Prove the derived surfaces (contracts.test.ts)
expectDerivedSurfaces(def, samples) walks the ServiceDef and the derivations bb plugin build performs: route paths, JSON Schemas, the CLI usage line, the tool name and presentation, and renderText on your sample. Give one sample per method that declares renderText; both counter methods do, and a declared renderText with no sample is reported as a problem. It throws one Error listing every problem. Every plugin suite calls it once per service (D20).
import { contractJsonOf } from "@get-bb/plugin-sdk/contracts";
import { expectDerivedSurfaces } from "@get-bb/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
import { changed, counter } from "./contracts.js";
describe("counter contracts", () => {
it("derives HTTP, CLI, tool, SDK, and docs from the one definition", () => {
expectDerivedSurfaces(counter, { get: { input: {}, output: { value: 3 } }, bump: { input: { by: 1 }, output: { value: 4 } } });
const [service] = contractJsonOf([counter]).services;
expect(service).toMatchObject({ id: "counter/counter", cli: { group: ["counter"] }, methods: {
get: { kind: "query", command: null, tool: null },
bump: { kind: "mutation", command: "counter/counter.bump", expose: { tool: true }, tool: { name: "counter_counter_bump" } },
} });
expect(changed).toMatchObject({ name: "counter/changed", mode: "emit", scope: "server", wire: { to: ["client"], key: null } });
});
});bb counter bump --by 3 and counter_counter_bump are now guarded: rename a method or break a presentation and this test fails before the build does. get carries tool: null because only bump declares expose: { tool: true }. contractJsonOf is what the build writes to dist/contract.json.
3. Run the server tier through the real loader (server.test.ts)
createTestPlugin writes a synthetic artifact from your definePlugin product and contracts, then boots the real Loader with :memory: SQLite, an in-memory kv and preferences store, and the kernel hook services. Pass isolation: "worker" to run the same module through the real worker bootstrap over in-memory ports, no thread. A plugin cannot tell the two apart, so run both.
import { createTestPlugin } from "@get-bb/plugin-sdk/testing";
import type { TestPluginHandle } from "@get-bb/plugin-sdk/testing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { counter, STEP_KEY } from "./contracts.js";
import type { Value } from "./contracts.js";
import server from "./server.js";
const AGENT = { kind: "agent", id: "thr_test" } as const;
const manifest = {
id: "counter",
provides: { "counter/counter": { version: "1.0.0" } },
contributes: { database: true, cli: { commands: ["counter"] } },
};
describe.each(["inproc", "worker"] as const)("counter/counter (%s)", (isolation) => {
let plugin: TestPluginHandle;
beforeEach(async () => {
plugin = await createTestPlugin({ manifest, server, contracts: [counter], isolation });
});
afterEach(() => plugin.dispose());
it("activates, bumps, and survives a reload", async () => {
expect(plugin.status().status).toBe("running");
const svc = await plugin.inject(counter);
await expect(svc.bump({ by: 2 })).resolves.toEqual({ value: 2 });
await plugin.reload();
await expect(svc.get({})).rejects.toMatchObject({ code: "stale_handle" });
const again = await plugin.inject(counter);
await expect(again.get({})).resolves.toEqual({ value: 2 }); // data.db outlives the generation
});
it("answers the derived command and the tool face with the same handler", async () => {
const outcome = await plugin.dispatch<Value>("counter/counter.bump", { by: 1 });
expect(outcome).toMatchObject({ ok: true, result: { value: 1 } });
const viaTool = await plugin.tool("counter_counter_bump").execute({ by: 2 }, { actor: AGENT });
expect(viaTool).toEqual({ value: 3 });
await expect(plugin.inject(counter).then((s) => s.bump({ by: 0 }))).rejects.toMatchObject({ code: "invalid_input" }); // by: min(1)
});
it("publishes counter/changed with the new value on every bump", async () => {
const seen: Value[] = [];
const scope = plugin.container.root.extend({ test: "listener" }, "custom");
await scope.events.on("counter/changed", (payload) => void seen.push(payload), { global: true });
const svc = await plugin.inject(counter);
await svc.bump({ by: 1 });
await svc.bump({ by: 1 });
expect(seen).toEqual([{ value: 1 }, { value: 2 }]);
await scope.dispose();
});
it("writes the step preference the way the Settings form does", async () => {
await plugin.preferences.set(STEP_KEY, 4); // kernel/preferences.set, CAS, changed event
expect(await plugin.preferences.get(STEP_KEY)).toBe(4);
});
});Points to notice. inject returns what another plugin's ctx.inject returns, as the operator. dispatch returns the envelope and never throws for a command failure. tool(name).execute requires opts.actor; an actor kind outside the method's actors answers forbidden. The event arrives because the intro's server.ts declares changed before ctx.provide and publishes { value } inside bump; the listener sits outside the plugin's scope subtree, so global: true is required, and there is no event face on the handle (§9.2).
When your plugin injects a sibling's service, load a fake provider first with with. Each entry is a full TestPluginSpec; available puts a spec in the catalog without loading it until plugin.load(id).
const plugin = await createTestPlugin({
manifest: { ...manifest, uses: { "audit/log": "^1.0.0" } },
server, contracts: [counter],
with: [{
manifest: { id: "audit", provides: { "audit/log": { version: "1.0.0" } } },
contracts: [auditLog],
server: definePlugin({ async activate(ctx) { await ctx.provide(auditLog, { record: async () => null }); } }),
}],
});4. Render the slot in the real UI kernel (app.test.ts)
createAppHarness boots createUiKernel and KernelRoot with react-dom in happy-dom. The core is fake: a recording fetch answers the bootstrap, preference reads and writes, and the routes you give it; socket is a FakeRealtimeSocket; server services are faked on the client container with fakeService, so useService(counter) resolves on first render. The module under test is the intro's src/app.tsx: valueQuery fetches client.query(counter.id, "get", {}), which is GET /api/v1/counter/counter/get, and invalidates on counter/changed.
sidebar.footer is declared by ui-sidebar, which is not in the test. The harness's shell option is a synthetic root occupant that declares the child slots you name and renders each in a <section data-slot> with the props you supply. Declare the JSON fields of the owner props; callbacks such as closeOnMobile pass through props untouched.
import { createAppHarness, fakeService } from "@get-bb/plugin-sdk/testing";
import type { AppHarness } from "@get-bb/plugin-sdk/testing";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { builtinEnvironments } from "vitest/environments";
import { z } from "zod";
import app from "./app.js";
import { counter, STEP_KEY } from "./contracts.js";
const dom = await builtinEnvironments["happy-dom"].setup(globalThis, {});
afterAll(() => dom.teardown(globalThis));
const GET_ROUTE = "/api/v1/counter/counter/get";
const waitFor = async (h: AppHarness, ok: () => boolean): Promise<void> => {
for (let i = 0; i < 100; i += 1) { if (ok()) return; await h.act(() => new Promise((r) => setTimeout(r, 10))); }
throw new Error(`timed out; panel:\n${h.element.innerHTML}`);
};
const button = (h: AppHarness) => h.element.querySelector<HTMLButtonElement>("button");
let harness: AppHarness | null = null;
afterEach(async () => { await harness?.unmount(); harness = null; });
describe("counter app tier", () => {
it("shows the count, refetches on counter/changed, and bumps by the step preference", async () => {
let value = 4;
let closed = 0;
const bumps: number[] = [];
harness = await createAppHarness({
plugins: [{ id: "counter", app }],
shell: {
children: { "sidebar.footer": { kind: "list", scope: "root", props: z.looseObject({ compact: z.boolean() }) } },
props: { "sidebar.footer": { compact: false, closeOnMobile: () => { closed += 1; } } },
},
routes: { [GET_ROUTE]: () => ({ value }) },
services: [fakeService(counter, {
get: async () => ({ value }),
bump: async ({ by }) => { bumps.push(by); value += by; return { value }; },
})],
preferences: { [STEP_KEY]: 3 }, // counter/step in the bootstrap
});
const h = harness;
await waitFor(h, () => button(h)?.textContent === "count 4"); // compact: false renders the label
const fetches = h.requests.filter((r) => r.url.includes(GET_ROUTE)).length;
value = 9;
await h.act(() => h.socket.event("counter/changed", { value: 9 }));
await waitFor(h, () => button(h)?.textContent === "count 9"); // through the real 50 ms debounce
expect(h.requests.filter((r) => r.url.includes(GET_ROUTE)).length).toBeGreaterThan(fetches);
expect(h.socket.sent).toContainEqual(expect.objectContaining({
t: "subscribe", targets: expect.arrayContaining([{ name: "counter/changed", key: null }]),
}));
await h.act(() => button(h)?.click());
await waitFor(h, () => bumps.length === 1);
expect(bumps).toEqual([3]); // usePreference(stepRef) read counter/step
expect(closed).toBe(1); // props.closeOnMobile() ran
});
});The harness runs on the real clock, so you poll inside act instead of asserting right after socket.event(...). fakeService needs every handler of the contract, including ones the component never calls. With compact: true the intro's button renders + instead of the label; add a second case if you render compact.
5. Call a host command without a process (host.test.ts)
The intro's counter has no host tier. Give it one rpc role for this step: contributes.hostRoles: [{ role: "rpc", name: "clock" }], hostCommands: ["host-uptime"], a defineHostCommands({ id: "counter", commands: { "host-uptime": { input: z.strictObject({}), output: z.strictObject({ pid: z.number().int(), uptimeMs: z.number() }) } } }) in contracts.ts, and in host.ts a defineHostEntry({ roles: [defineHostRole({ role: "rpc", name: "clock", contract: counterHost, commands: { "host-uptime": async () => ({ pid: process.pid, uptimeMs: process.uptime() * 1000 }) } })] }).
fakeRoleContext builds a RoleContext whose fs/exec/vcs/process ports throw host/unsupported unless you pass them, and records signals, events, and logs. entry.commands is the erased table the bootstrap dispatches to, so narrow before calling.
import { fakeRoleContext } from "@get-bb/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
import entry from "./host.js";
describe("counter/clock (rpc)", () => {
it("answers host-uptime from this process and validates its input", async () => {
const ctx = fakeRoleContext({ pluginId: "counter", role: "rpc", name: "clock" });
const handler: unknown = entry.commands["host-uptime"];
if (typeof handler !== "function") throw new Error("no host-uptime");
await expect(handler({}, ctx.context)).resolves.toMatchObject({ pid: process.pid });
await expect(handler({ extra: 1 }, ctx.context)).rejects.toMatchObject({ code: "invalid_input" });
expect(ctx.signals).toEqual([]);
});
});To test the server half's ctx.hostClient(counterHost, "clock"), pass host: entry to createTestPlugin: the harness runs every contributes.hostRoles row over a fake RoleContext and plugin.host.role("clock").call(...) dispatches exactly as the bootstrap does (§9.5).
6. Run the suite
In the bb tree: pnpm exec turbo run typecheck test lint --filter=@get-bb/plugin-<id>. Out of tree: pnpm test (vitest run). The contracts test's dist/ assertions, if you add any, run only after bb plugin build ..
7. Know what the smoke proves
tools/smoke/stage1.sh is the only check that talks to a real core. It builds hello-slot and the app, starts bb-server --patch examples/hello-slot.composition.yaml in a temp data dir, installs with bb plugin install path:..., and asserts end to end: GET /api/v1/hello-slot/greeter/greet?name=bb answers; bb hello greet bb answers in text and --json; the SDK answers and the method table and bb guide reference hello-slot list the tool; bb composition dump shows the row running; Playwright loads /hello and a CLI bb hello count reaches the page over realtime; bb hello host-greet bb answers from the rpc role process with that process's pid, with the host artifact published as artifacts/<sha256>.tgz; SIGTERM exits 0 and plugins/data/hello-slot/data.db survived. Nothing is mocked. The harnesses prove your code against the kernel's invariants; the smoke proves the wiring between processes.
What happens at runtime
createTestPlugin:package.jsonnamed@get-bb/plugin-<id>1.0.0 anddist/contract.jsonare generated from your spec; one composition row per bundled plugin;createTestLoader({ kernelServices: true })boots.isolation: "worker"makes the sourcenpmand inserts the row through the user layer so the loader places it ininlineWorkerFactory.createAppHarness:IS_REACT_ACT_ENVIRONMENT = true;history.replaceState(path ?? "/"); storages cleared; thetest-shellplugin is prepended whenshellis given; eachsetupruns through the collector to build the bootstrap descriptor;servicesare provided on the client container root;KernelRootrenders andkernel.boot()runs insideact. Unknown routes answer a 404unknown_methodenvelope.fakeRoleContext: no process, no filesystem;lifecycle.signalis the test'sAbortController; pass a real temp dir asdataDirwhen the role writes files.expectDerivedSurfacesboots nothing and runs the derivations the build runs.
Pitfalls
- Write
setupsynchronous. The browser and the build await a Promise; the harness refuses one withinvalid_contract(D20). - Install happy-dom by hand at module scope, never with a
// @vitest-environment happy-domdocblock: the/testingentry also loads the server-tier harness (kernel-loader → kernel-store), whose module scope resolves its migrations directory from afile:import.meta.urlthat vitest's happy-dom environment rewrites tohttp:(§9.3). - A root occupant cannot declare a
thread- orpane-scoped child. Thread- and pane-scoped slots are tested at the type level until a pane-level mount exists (§9.3). - Declared input
.default()and.transform()do not reach a handler in a worker (D7). Wrap handlers withwithDefaults(counter, {...})as the intro does, and keepdescribe.each(["inproc", "worker"]). opts.actorontool(name).executehas no default;plugin.preferences.setworks withoutcontributes.settings, which matters only for therequiredfast path (§9.2).createTestPluginwiresctx.hostClientto the fake role even where the product did not at6f3d4592f(D15); core wires it atdc07292bf(§0.7). Keep a fallback for an offline host (§3.9).- Spec 01 §10's
createKernelandloadPlugin(kernel, { dir })are not built:createTestContainer()is the kernel andloadPlugintakes a built artifact. Tiers 2 and 3 (corpus replay, screenshot parity) belong to@bb/test-kit, not these harnesses (§9).
See also
- Reference §9 (every harness member), §9.8 (the four sketches), §9.6 (what
expectDerivedSurfaceschecks per surface), §8 (the derivations it replays); Introduction §2 (the plugin under test). next/examples/plugins/hello-slot/src/{contracts,server,app,host}.test.tsfor the full versions, including theenvironment-providerrole over a real temp dir.next/tools/smoke/stage1.shfor the end-to-end steps.