Test plugin factories and contracts
The @get-bb/plugin/testing entry supplies Node-only tools for plugin tests. Its tier subpaths load real factories with controlled dependencies. This page tests a small counter plugin across its contract, server, app, and host tiers.
Use this when
- Check a contract before a build.
assertContractfinds invalid declarations and schema problems. - Run a server factory with controlled ports.
createServerHarnessuses the real loader graph and commit rules. - Replace one service in a test. A named service double gives typed calls without a broad runtime fake.
- Mount one declared surface.
renderSurfacesupplies owner props, service doubles, routing, settings, and winner changes. - Check cleanup and reload behavior. The harnesses stop generations and make old service handles stale.
- Call a host role without a daemon.
createHostHarnesschecks schemas, signals, cancellation, and retained leases.
The test entries
Import each helper from its runtime-specific subpath.
| Import | Purpose |
|---|---|
@get-bb/plugin/testing |
Common contract checks, import scans, deferred values, and shared types. |
@get-bb/plugin/testing/server |
Server factories, service doubles, loader drivers, and call records. |
@get-bb/plugin/testing/app |
App factories, surface mounts, app service doubles, and browser drivers. |
@get-bb/plugin/testing/host |
Host factories, role calls, signals, and lease checks. |
@get-bb/plugin/testing/process |
Shell arguments and process setup markers. |
The product build rejects an import from any /testing path. Keep all such imports in test files.
Domain fixtures stay with their owner plugin. For example, use @bb/threads/testing for thread data. Use @bb/workspace/testing for workspace data.
What you build
The example plugin owns the acme.counter.service server service. It also claims the bb.thread-ui.sidePanels app surface. Its app factory uses the counterClient app service token.
acme-counter/
├── bb.plugin.jsonc
├── dist/
│ └── contract.json
├── src/
│ ├── contracts.ts
│ ├── app.tsx
│ ├── server.ts
│ └── host.ts
└── test/
├── contract.test.ts
├── server.test.ts
├── app.test.tsx
└── host.test.tsThe build generates dist/contract.json. Do not edit that file. The harness reads it as loader input.
1. Check the declarations
Use the common entry for checks that need no runtime tier.
// test/contract.test.ts
import { readFile } from "node:fs/promises";
import { assertContract, scanPublicImports } from "@get-bb/plugin/testing";
import { describe, expect, it } from "vitest";
import { counterClient, counterService, counterStatusSurface } from "../src/contracts.js";
describe("counter contracts", () => {
it("accepts every source declaration", () => {
assertContract(counterService);
assertContract(counterClient);
assertContract(counterStatusSurface);
});
it("writes the expected public contract", async () => {
const text = await readFile(
new URL("../dist/contract.json", import.meta.url),
"utf8",
);
const contract = JSON.parse(text) as {
declarations: Array<Record<string, unknown>>;
};
expect(contract.declarations).toContainEqual(
expect.objectContaining({
id: "acme.counter.service",
type: "service",
kind: "single",
}),
);
});
it("uses only public package exports", async () => {
const result = await scanPublicImports(
new URL("..", import.meta.url).pathname,
);
expect(result.violations).toEqual([]);
});
});assertContract checks a data-only declaration. You can pass ContractSamples when a contract needs example values. The function reports all contract problems in one failure.
The generated file gives a second check. It records each ID, owner, tier, kind, version, stability value, schema, and host role.
Use dot-separated contract IDs. The example uses acme.counter.service, not a path-like ID.
2. Run the server factory
The server harness runs the real loader graph. It stages all factory work before one atomic commit.
// test/server.test.ts
import { readFile } from "node:fs/promises";
import { createServerHarness, type ServerHarness } from "@get-bb/plugin/testing/server";
import { afterEach, describe, expect, it } from "vitest";
import { counterService } from "../src/contracts.js";
import server from "../src/server.js";
const readJson = async <T>(path: string): Promise<T> =>
JSON.parse(await readFile(new URL(path, import.meta.url), "utf8")) as T;
describe.each(["inProcess", "worker"] as const)("counter server: %s", (placement) => {
let harness: ServerHarness | undefined;
afterEach(async () => {
await harness?.dispose();
});
it("provides the service and rejects an old handle after reload", async () => {
harness = await createServerHarness({
plugin: {
manifest: await readJson("../test/fixtures/bb.plugin.json"),
contract: await readJson("../dist/contract.json"),
server,
},
placement,
});
const counter = await harness.services.use(counterService);
await expect(counter.increment({ by: 2 })).resolves.toEqual({ value: 2 });
await harness.drivers.reload();
await expect(counter.read({})).rejects.toMatchObject({
code: "stale_handle",
});
const current = await harness.services.use(counterService);
await expect(current.read({})).resolves.toEqual({ value: 2 });
expect(harness.inspection.status().status).toBe("running");
});
});The fixture is the parsed form of bb.plugin.jsonc. Keep it equal to the source manifest. A small JSON fixture avoids a JSONC parser in each test.
Test both placements when the plugin can run in a worker. Both modes use the same typed factory API. The worker mode also checks serialization boundaries.
The harness controls the kernel ports. These ports are bb.storage, bb.secrets, bb.preferences, bb.realtime, bb.http, bb.rpc, and bb.plugins.
Use the ports option to replace a port. Use providers to supply plugins for required service edges.
3. Use a named service double
Do not create one object that copies the complete plugin API. Replace only the service token that the factory uses.
const auditCalls: Array<{ value: number }> = [];
harness.services.stub(auditService, {
async record(input) {
auditCalls.push(input);
return { accepted: true };
},
});
await harness.drivers.reload();
const counter = await harness.services.use(counterService);
await counter.increment({ by: 3 });
expect(auditCalls).toEqual([{ value: 3 }]);Each double binds to one ServiceToken<T>. TypeScript checks its complete implementation. The double also records typed calls.
A required service must exist before the first factory run. Put its test provider in ServerHarnessOptions.providers. Then use stub for a later provider change.
inspection.calls records service and port calls. inspection.callsTo(path) selects calls for one contract path. inspection.registrations() shows the committed claims.
The harness can also drive public behavior. Use callRpc, runCli, fetchHttp, runService, or runSchedule. Domain drivers can emit thread events and call agent tools.
4. Mount an app surface
renderSurface runs the real app factory. It mounts one static surface claim with controlled owner props.
// test/app.test.tsx
import { renderSurface, type RenderedSurface } from "@get-bb/plugin/testing/app";
import { afterEach, describe, expect, it } from "vitest";
import app from "../src/app.js";
import { counterClient } from "../src/contracts.js";
import manifest from "./fixtures/bb.plugin.json" with { type: "json" };
describe("counter side panel", () => {
let surface: RenderedSurface | undefined;
afterEach(async () => {
await surface?.unmount();
});
it("reads and changes the counter through a service double", async () => {
let value = 3;
const serviceCalls: string[] = [];
surface = await renderSurface({
manifest,
app,
contractId: "bb.thread-ui.sidePanels",
key: "acme.counter",
props: { threadId: "thr_test" },
services: [{
token: counterClient,
implementation: {
async read() {
serviceCalls.push("read");
return { value };
},
async increment({ by }) {
serviceCalls.push("increment");
value += by;
return { value };
},
},
}],
settings: { "acme.counter.step": 2 },
route: "/threads/thr_test",
});
expect(surface.element.textContent).toContain("Count: 3");
surface.element.querySelector<HTMLButtonElement>("button")?.click();
expect(surface.element.textContent).toContain("Count: 5");
expect(serviceCalls).toEqual(["read", "increment"]);
expect(surface.inspection.serviceCalls).toHaveLength(2);
});
});The services array contains app-plane service doubles. It does not make a server service available in the browser. Use an owner app client or bb.rpc for that boundary.
The renderer applies the rules for single, list, keyed, and chain contracts. Supply key for a keyed claim or a selected list item.
Every replaceable surface or service joins the global winner picker. The picker does not use plugin load order as policy.
A replaceable single surface receives a typed Original component. The harness supplies that component when it mounts the winning wrapper.
Use behavior.flipWinner(contractId, pluginId) to test the global winner picker. A winner change remounts the surface. Read inspection.remountCount to check that change.
Use behavior.setService to add, replace, or remove a service double. Use behavior.emitRealtime to send a declared realtime event.
Call rerender when owner props change. Call unmount after each test. Unmount stops the generation signal and runs all automatic factory cleanup.
Use loadAppPlugin when you need the captured factory without a surface mount. Use installTestAppRuntime for a low-level app test that loads the factory directly.
5. Check a host role
The host harness calls one private role through its declared schemas.
// test/host.test.ts
import { createHostHarness } from "@get-bb/plugin/testing/host";
import { describe, expect, it } from "vitest";
import { counterHostRole } from "../src/contracts.js";
import host from "../src/host.js";
describe("counter host role", () => {
it("reads the machine counter and releases all leases", async () => {
const harness = createHostHarness(host, counterHostRole);
await expect(harness.call("read", {})).resolves.toEqual({ value: 0 });
expect(harness.signals()).toEqual([]);
await harness.dispose();
expect(harness.lifecycleSignal.aborted).toBe(true);
expect(harness.retainedLeaseCount()).toBe(0);
});
});The harness validates input, output, JSON transfer, and cancellation. Supply controlled fs, exec, or watch ports when the role needs them.
What happens at runtime
createServerHarness starts a loader generation and runs the server factory with a typed api object. It commits all claims together. dispose aborts the generation and runs registered cleanup.
renderSurface runs the app factory and selects one declared claim. It gives the component owner props and test services. It mounts the selected contract under the app test runtime.
createHostHarness runs a host factory without a daemon process. Each call crosses the same schema and cancellation boundary as a real role call.
The runtime owns cleanup for all API registrations. A factory can also return one disposer or call api.onDispose for an external resource.
Pitfalls
- Do not import a test entry from
src/app.tsx,src/server.ts, orsrc/host.ts. The product build rejects it. - Do not edit
dist/contract.json. Rebuild it frombb.plugin.jsoncand the source declarations. - Keep contract IDs dot-separated. Use the exact ID in the manifest, token, harness, and generated contract.
- Give each service double one named token. A broad fake can hide an undeclared service edge.
- Use
providersfor required services. The loader does not start a consumer before its required providers exist. - Always call
disposeorunmount. These calls also check factory cleanup and generation aborts. - Test a replaceable contract through the winner picker. Do not use plugin load order as the expected policy.
- A server service has no
Original. Only a replaceablesingleapp surface receivesOriginal.
See also
- Design 02, Authoring a plugin, section Testing entry.
- Design 01, Kernel and plugin lifecycle, for
contract.json, commit order, and winner changes. - The app surface guide for the four contract kinds and
Original. - The services guide for required, optional, and watched edges.