9. Testing — @get-bb/plugin-sdk/testing

One import path for a plugin's tier-1 harnesses and the tier-4 conformance check (12 §1: tier 1 proves the kernel invariants a plugin relies on; tier 4 proves every defineService method reaches HTTP, SDK, CLI, tool, and docs). Node only; the product never loads it, and a server build that imports /testing fails (as-built D19). Tiers 2 and 3 (corpus replay, screenshot parity) belong to @bb/test-kit and take the built plugin, not these harnesses (as-built §5).

Spec 01 §10 sketches createKernel + loadPlugin(kernel, { dir }). Built: there is no createKernel; createTestContainer() is the test kernel, loadPlugin takes a built artifact (dir: sources are not compiled: packages/kernel-loader/src/testing/index.ts), and createTestPlugin is the SDK's wrapper that writes the artifact for you. Built wins.

Run a plugin's suite with pnpm exec turbo run typecheck test lint --filter=@get-bb/plugin-<id> (as-built §5). Every plugin ships the four files the example has: contracts, server, app, host tests as applicable (examples/plugins/hello-slot/src/*.test.ts).

9.1 What each harness runs

Harness Real Fake Source
createTestPlugin (server tier) The real Loader through kernel-loader's createTestLoader: manifest validation, container.add(), dispose order, :memory: SQLite behind the same envelope-shaped handle, the kernel hook services mounted (kernelServices: true), generations and stale_handle. isolation: "worker" runs the same module through inlineWorkerFactory (the real worker bootstrap over createPortPair ports, no thread). In-memory Storage, memoryKv, memoryPreferencesStore, a FakeClock, host roles over fakeRoleContext, hostClients you pass. packages/plugin-sdk/src/testing/server.ts
createAppHarness (app tier) The real UI kernel (createUiKernel + KernelRoot) rendered by react-dom in happy-dom, the web ShellHost, the plugin's definePluginApp product imported through the kernel's seam, slot arbitration, routes, defineQuery/usePreference over the wire. The core: a recording fetch that answers the bootstrap, preference get/set, and your routes; a FakeRealtimeSocket; server services faked on the client container with fakeService. packages/plugin-sdk/src/testing/app.ts
fakeRoleContext (host tier) Your role handlers, called exactly as kernel-host's bootstrap calls them (roles[role][name][command]). The RoleContext: every port is a throwing stub unless you pass it; signals, events, logs, and retains are recorded; lifecycle.signal is the test's AbortController. No process, no filesystem. packages/plugin-sdk/src/testing/host.ts
expectDerivedSurfaces (tier 4) Walks the ServiceDef and the derivations bb plugin build performs (routes, CLI tree, tool spec, docs). Nothing is booted. packages/kernel-contract/src/testing/index.ts

9.2 createTestPlugin

createTestPlugin(init: CreateTestPluginInit): Promise<TestPluginHandle>. It builds a synthetic artifact from the spec (package.json named @get-bb/plugin-<id> 1.0.0; bb = { id, name: id, description } plus server/contracts/host entry paths for the fields you pass, then the rest of manifest), generates dist/contract.json from contracts, writes a composition with one row per bundled plugin (the plugin's config on its row), and boots createTestLoader with kernelServices: true. With isolation: "worker" the plugin's source becomes npm and its row is inserted through the user layer, so the loader places it in the inline worker (01 §5; spec 01 §10 "or worker to exercise the proxy"; D20: both placements).

Member Signature (abridged) What it does Spec As built
TestPluginSpec { manifest: { id } & JsonObject; server?: Plugin; contracts?: ServiceDef[]; host?: HostEntry; files?: Record<string,string>; source?: "bundled" | "path" } One plugin: package.json#bb minus the defaults, the definePlugin product, the defineService objects (dist/contract.json is generated), the defineHostEntry product, extra artifact files (config.schema.json, skills/…). contributes.settings is needed only for the requiredneeds-configuration fast path; ctx.preferences.define/get/set and plugin.preferences.set work without it. 01 §10 server.ts
CreateTestPluginInit TestPluginSpec & { config?; isolation?: "inproc" | "worker"; with?: TestPluginSpec[]; available?: TestPluginSpec[]; roleContext?; hostClients?: Record<string, HostClient>; env?: Record<string,string> } with: dependencies loaded first (fake providers of sibling contracts). available: in the catalog but not in the composition until load(id). roleContext: the FakeRoleContextInit every hostRoles row runs against (role/name/pluginId come from the row). hostClients: ctx.hostClient(contract, name) targets by <pluginId>/<name>; the spec's own host entry is wired for every contributes.hostRoles row and yours win on collision. env: $env for the rows (01 §7.4), never the process environment. 01 §10 server.ts
TestPluginHandle.id / .harness / .container / .clock string / TestLoader / Container / FakeClock The loader face underneath (9.7), the real container, the fake clock. Observe a plugin event through the container: const scope = plugin.container.root.extend({ test: "listener" }, "custom"); await scope.events.on("hello-slot/greeted", (p) => { seen.push(p); }, { global: true }); then await scope.dispose()global: true is required because the listener sits outside the emitting plugin's scope subtree (§3.5; hello-slot server.test.ts). There is no event face on the handle. server.ts
status() () => PluginRuntimeStatus The row's loader status (running, degraded, needs-configuration, …). 01 §10 harness.loader.status(id)
reload() () => Promise<PluginRuntimeStatus> Generation N+1; every handle injected before answers stale_handle; activate re-runs. 01 §4.5, §10 harness.loader.reload(id)
load(id) (id: string) => Promise<PluginRuntimeStatus> Inserts the row of an available or with plugin at the end of the composition, as bb plugin install would; not_found for any other id. harness.loader.insertRow
inject(contract, range?) <C>(contract: C, range = "*") => Promise<HandleOf<C>> A consumer-side handle from a fresh root child scope as the operator ({ kind: "human", id: "local" }): what another plugin's ctx.inject returns. The scope lives until dispose(). 01 §10 server.ts
dispatch(command, input, actor?) <T>(command: string, input: JsonValue, actor = operator) => Promise<Envelope<T>> Parses the command name, dispatches through a scope tagged with actor, returns the envelope (a parse failure is returned, not thrown), disposes the scope. 03 §9 scope.commands.dispatch
tool(name) (name) => { execute(input: JsonObject, opts: { actor: Actor }): Promise<JsonValue> } The tool face of an expose.tool method: matches tool.name from the row's contract.json or the default <id>_<service>_<method> in snake case, injects the built contract as actor, JSON-round-trips the result; not_found when the plugin declares no such tool. opts.actor is required (no default); the agent path is { actor: { kind: "agent", id: "thr_test" } }, and a kind outside MethodDef.actors answers forbidden. 03 §6, 01 §10 server.ts
preferences.get / .set get(key, scope = "profile", threadId = null) => Promise<JsonValue | null>; set(key, value, scope?, threadId?) => Promise<void> get reads the store. set goes through the kernel/preferences service (^1) with expectedUpdatedAt: null, as the Settings form and bb preferences set write: the store's CAS runs and kernel/preferences.changed reaches the plugin's watchers. 02 §3.7 server.ts
host HostCommandCaller | null The host entry's role rows over fake RoleContexts (9.5); null when the spec has no host. Signals a role emits reach the server half's hostClient.onSignal. 05 §4.7 hostCaller in server.ts
dispose() () => Promise<void> Disposes the inject scopes, then the loader. server.ts

9.3 createAppHarness

createAppHarness(init: AppHarnessInit): Promise<AppHarness>. Boot order: IS_REACT_ACT_ENVIRONMENT = true; history.replaceState(path ?? "/"); localStorage/sessionStorage cleared; a synthetic test-shell plugin is prepended when shell is given; each plugin's setup runs once through the collector to produce its bootstrap descriptor (version 1.0.0, generation 1, status: "running"); services are provided on the client container's root; KernelRoot renders inside act; kernel.boot() runs inside act (07 §2). The fake core answers …/kernel-ui/bootstrap/get with the synthesized bootstrap, …/kernel/preferences/get with { value: null, updatedAt: null }, …/kernel/preferences/set with { updatedAt }, your routes by pathname (input from the JSON body or the ?input= query), and everything else with a 404 unknown_method envelope; a route that throws returns the KernelError (or plugin_error) as a 404 envelope.

Member Signature (abridged) What it does Spec As built
AppPluginSpec { id; app: PluginApp; version?; replaces?: string | null; config?: JsonObject } One app-tier plugin: the definePluginApp product. config reaches setup(app, config) in both the claim pass and the kernel's load (01 §2.4: the row config minus serverOnly keys); omitted = {}. The product passes {} today, so only the harness delivers a config (README deviation 12). 01 §2.4 moduleOf in app.ts
AppHarnessInit.plugins AppPluginSpec[] The plugins the bootstrap lists, in order. 07 §2 app.ts
.services FakeService[] Server services faked on the client container before boot, so useService(def) resolves on first render. 02 §9 service.provide(kernel.container.root)
.routes Record<string, (input: JsonValue, { method }) => JsonValue | Promise<JsonValue>> HTTP routes the fake core answers, keyed by pathname (/api/v1/<pluginId>/<service>/<method>); what defineQuery fetches. 03 §3 app.ts
.preferences Record<string, JsonValue> Profile-scope preferences the bootstrap carries (updatedAt: 1). 07 §2 step 6 app.ts
.path / .layoutMode string / "auto" | "compact" | "regular" The URL the tab opens on (default /); the bootstrap's layout mode (default auto). 07 §2 app.ts
.shell { children: Record<string, SlotDeclarationInput>; props?: Record<string, Record<string, unknown>>; render?: Record<string, RenderSlotOptions> } A synthetic root occupant with a / route and a test-shell-home pane kind, so the kernel renders root instead of its NoRoutePage. It declares children under root and renders each in <section data-slot="<name>"> with props[name] and render[name] (the keyed ladder and explicit thread key, 07 §5.5). Omit when one of plugins registers root. 07 §3.1, §5.3 shellApp in app.ts
AppHarness.kernel / .socket / .shell / .element / .scope UiKernel / FakeRealtimeSocket / ShellHost / HTMLElement / Scope The kernel; the socket to push frames through; the web shell host; the <div> KernelRoot is mounted in; the client container's root scope (02 §11) for events.on/commands assertions. 07 §2 app.ts
.requests Array<{ method; url; body: string | null }> Every fetch the kernel made, in order. app.ts
.provide(contract, handlers, facts?) <C>(…) => Promise<Disposer> Fake a server service after boot (the degraded → bound transition, 07 §7.7). 07 §7.7 fakeService(...).provide(root)
.act(fn) <T>(fn: () => T | Promise<T>) => Promise<T> Runs fn inside React's act and flushes the kernel's microtasks. Wrap every socket push, click, and wait step. It does not advance timers: the harness builds the kernel on the real clock (no clock in its createUiKernel call), so the 50 ms invalidation debounce (§4.8) is a real setTimeout; poll inside act (snippet below) instead of asserting right after socket.event(...). app.ts run; hello-slot app.test.ts waitFor
.unmount() () => Promise<void> Unmounts the root inside act, removes the element, disposes the kernel. Call it in afterEach. app.ts
// hello-slot app.test.ts: wait for a refetch through the real 50 ms debounce
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}`);
};
await h.act(() => h.socket.event("hello-slot/greeted", { name: "cli", count: 7 }));
await waitFor(h, () => text(h, "hello-slot-count") === "count 7");

Rules (D20, README deviations 10–11, examples/plugins/hello-slot/vitest.config.ts):

  • setup is synchronous. The harness runs it through the collector to build the descriptor the kernel compares against at load (07 §5.9); a setup that returns a Promise is refused with invalid_contract "setup must be synchronous in the harness". The browser and the build hook await a Promise (§4.1); the harness alone refuses one, so a synchronous setup passes all three.
  • Root-scoped children only. A root occupant cannot declare a thread/pane child (07 §5.3). Thread- and pane-scoped slots are tested at the type level until a pane-level mount exists (README "requests": kernel-ui).
  • Install happy-dom by hand. Every test file runs in node. app.test.ts installs the DOM at module scope with const dom = await builtinEnvironments["happy-dom"].setup(globalThis, {}) and tears it down in afterAll, instead of a // @vitest-environment happy-dom docblock: the /testing entry also loads the server-tier harness (kernel-loader → kernel-store), whose module scope resolves its migrations directory from a file: import.meta.url that vitest's happy-dom environment rewrites to http:.

9.4 fakeService, fakeShellHost, FakeRealtimeSocket

Member Signature (abridged) What it does Spec As built
fakeService(contract, handlers, facts?) <C extends AnyContract>(contract: C, handlers: HandlersOf<C>, facts?: FactsOf<C>) => FakeService A server service faked on the client container. A ServiceDef goes through provideService(scope, def, handlers, { facts }) (defaults and validation as the real provider); a bare contract through scope.provide(contractOf(contract), handlers, facts ?? {}). Every handler of the contract is required, even ones the panel never calls. 02 §9 app.ts
FakeService { provide(scope: Scope): Promise<Disposer> } What createAppHarness.services consumes and harness.provide builds. app.ts
fakeShellHost(win?, shellVersion?) (win: WebShellWindow = window, shellVersion = "0.0.0-test") => ShellHost The web tab's ShellHost over the test window: createWebShellHost({ window, shellVersion, gateDomains: [], report: () => {} }). iOS/Electron shells are faked by capability elsewhere. 10 §2 app.ts
FakeRealtimeSocket class implements RealtimeSocket The socket the harness hands the kernel. sent: unknown[] holds every frame the kernel sent, parsed (subscribe frames, 07 §7.10). onOpen fires on a microtask. deliver(frame) pushes one raw frame. event(name, payload, key = null, actor = { kind: "system", id: "kernel" }) wraps one wire event in a hub batch frame with meta { actor, time, eventId, process: "server", hostId: null } (02 §5.6). command(name, input, actor = { kind: "agent", id: "thr_test" }) delivers a core → client command and returns its commandId (02 §6.7). dropped(code = 1006) fires the close handler. 02 §5.6, §6.7 app.ts

9.5 fakeRoleContext and host callers

fakeRoleContext(init: FakeRoleContextInit = {}): FakeRoleContext (05 §6.3). Defaults: pluginId: "test-plugin", generation: "1", role: "rpc", name: "main", dataDir: /fake/plugins/host/<pluginId>, tempDir: <dataDir>/tmp, scope: { kind: "none" }. Pass a real temp dir as dataDir when the role writes files (host.test.ts).

Member Signature (abridged) What it does Spec As built
FakeRoleContextInit { pluginId?; generation?; role?: HostRoleName; name?; dataDir?; tempDir?; scope?; ports?: Partial<Pick<RoleContext, "fs" | "exec" | "vcs" | "process">> } Identity, paths, scope, and the ports. A port not in ports is a Proxy whose every member throws host/unsupported naming <port>.<member>, so a test sees exactly what the role touched. 05 §6.3 host.ts
FakeRoleContext { context: RoleContext; signals: { name; payload }[]; events: Omit<HostEventInput,"actor">[]; logs: { level; message }[]; lifecycle: AbortController; retained: { count }; onSignal(listener): () => void } context is what you pass to the handler. emitSignal/emitEvent/log.* record; retain() counts up and release() down; watch is a no-op disposer; lifecycle.signal is lifecycle's signal, so lifecycle.abort() cancels the role. 05 §6.3 host.ts
HostCommandCaller { roles: readonly HostRoleCaller[]; role(name: string): HostRoleCaller } TestPluginHandle.host: one caller per contributes.hostRoles row, by the row's name; role(name) throws not_found for an undeclared name. 05 §4.7 host.ts
HostRoleCaller (the role() result; not exported by name) { role; name; ctx: FakeRoleContext; client: HostClient; call(command, input): Promise<JsonValue>; exit({ exitCode, signal }): void } call dispatches roles[role][name][command] with (input, ctx.context) exactly as kernel-host's bootstrap does and unknown_method otherwise (the spec's commands-table fallback for rpc is not implemented: README deviation 8). client is what the server half's ctx.hostClient(contract, name) resolves to, with signals relayed to onSignal. exit fires hostClient.onExit listeners with roleId: <pluginId>:<role>/<name>. 05 §6.2 server.ts

Calling a role directly (no createTestPlugin): index entry.roles[role][name] yourself and pass ctx.context, as host.test.ts does for both the environment-provider role and the rpc role's commands table. The index is unknown (§6.1): narrow with typeof handler === "function" before the call, or export the defineHostRole row's typed handlers from host.ts and test those.

9.6 expectDerivedSurfaces

expectDerivedSurfaces(def: ServiceDef, samples: DerivedSurfaceSamples = {}): void (03 §7.4; 12 §2.4 K1). Mandatory in every plugin suite, once per service it defines (D20). It collects every problem and throws one Error listing all of them, prefixed expectDerivedSurfaces(<def.id>).

Surface What it asserts per method Spec As built
CLI tree buildCliTree([def]) reports no problems (reserved names, collisions). 03 §5 kernel-contract/src/testing/index.ts
HTTP routeOf(def, name, m).path starts with /. 03 §3 same
JSON Schema Non-custom methods have an object input schema and a non-empty output schema ("set jsonSchema when the validator has no converter"). 03 §2 same
CLI word and help expose.cli implies a cli entry in the MethodDoc; its usage line starts with bb <group…> <word>; the help text is non-empty and has no blank lines. Spec 03 §7.4 also asks that usage "parses back through the §5.4 parser"; built compares the usage structurally and leaves the parser round-trip to the cli package (derived/cli, 12 §5). 03 §5.4, §7.4 same
Tool custom methods cannot be tools. expose.tool implies a tool spec whose name matches ^[a-zA-Z0-9_-]+$ and is at most 64 characters, whose presentation has label.pending, label.completed, and icon, and whose inputSchema root is an object. 03 §6, 12 §5 derived/tool same
renderText With a sample, renderText(sample.output, { …DEFAULT_RENDER, input: sample.input }) returns a string and does not throw. A non-custom method that declares renderText without a sample is a problem. The "offline cli entry handler" part of 03 §7.4 is not checked here. 03 §7.4 same
Member Signature (abridged) What it does Spec As built
DerivedSurfaceSamples { readonly [method: string]: { input: unknown; output: unknown } } Per method, a sample output renderText must handle and the input it was produced from. Give one entry per method that declares renderText; omit a method and only its route, schema, CLI, and tool surfaces are checked. 03 §7.4 kernel-contract/src/testing/index.ts

9.7 Re-exports from the kernel packages

@bb/test-kit is not a plugin dependency; the SDK re-exports the "parked" helpers straight from the kernel packages that own them (test-kit README "Deviations": re-exported, not moved, because turbo refuses a package-graph cycle).

Member Signature (abridged) What it does Spec As built
FakeClock class { now(); setTimeout(fn, ms): () => void; advance(ms): Promise<void>; pending: number } Deterministic clock at 1_700_000_000_000; timers fire only through advance, in order, with microtasks flushed between them. TestPluginHandle.clock. 02 §12 kernel-core/src/test/index.ts
createTestContainer(init?) (init: Partial<ContainerInit> = {}) => { container: Container; clock: FakeClock } A real server container over a FakeClock and the async-local call store: the TestKernel loadPlugin takes. 02 §12; 01 §10 createKernel same
createPortPair() () => [DuplexPort, DuplexPort] Two linked in-memory duplex ports (what a MessageChannel gives a worker): JSON copies, async, ordered, delivered outside the sender's async context. 01 §5.2 same
flush(rounds?) (rounds = 50) => Promise<void> Drains the microtask queue rounds times (activation steps chain promises). same
expectDerivedSurfaces, DerivedSurfaceSamples see 9.6 03 §7.4 kernel-contract/src/testing/index.ts
createTestLoader(init) (init: TestLoaderInit) => Promise<TestLoader> The real Loader over in-memory ports: composition/user/patches YAML, synthetic plugins, optional container/clock, kernelServices, hostClients, env, workers (default inlineWorkerFactory), modules, fetch, hooks, preferences, onWorker, real storage/bundledStorage for worker_threads tests. createTestPlugin wraps it. 01 §10 kernel-loader/src/testing/index.ts
TestLoaderInit, TestLoader TestLoader = { loader; container; clock; storage; memory; bundled; installs; kv; preferences; logs; kernelLog; reducers; statuses; modules; paths; sqlite; sqliteCloses; dispose() } The loader face: recorded logs, status transitions, every reducers hand-off, the path: dirs as memory storages, every sqlite.close the loader sent. TestPluginHandle.harness. 01 §10 same
TestPlugin SyntheticPlugin & { module?: PluginServerModule; source?: "bundled" | "path" | "npm" } (SyntheticPlugin = { packageJson: { name; version; bb }; serverSource?; files?; builtAt? }) One synthetic artifact: the module in memory (or dist/server.mjs from storage), extra files, and where it comes from. 01 §3 same
loadPlugin(kernel, init) (kernel: { container; clock? }, init: LoadPluginInit) => Promise<LoadedPlugin> Boots one built artifact (plus with dependencies) through the real loader on an existing container. Spec 01 §10's dir: option is not built: pass the artifact files and the module. 01 §10 same
LoadPluginInit, LoadedPlugin LoadPluginInit = { artifact: TestPlugin; config?; isolation?; with?: TestPlugin[] }; LoadedPlugin = { id; harness: TestLoader; status(); reload(); tool(name); dispose() } The loader-level subset of TestPluginHandle (no inject/dispatch/preferences/host). 01 §10 same
memoryStorage(root?, files?, prefix?, clock?, watchers?) (root = "/mem", …) => MemoryStorage A Storage over a Map with implicit directories, watchers, setClock for mtime stamps, and :memory: SQLite handles per path. 01 §9 same
memoryKv() () => KvPort & { rows: Map<string, JsonValue> } The per-plugin kv port in memory, keyed <pluginId>\0<key>; list by prefix. 01 §9 same
inlineWorkerFactory(modules, openDb) (modules: ModuleLoader, openDb: (path) => Promise<SqliteHandle>) => WorkerFactory An in-process "worker": the real plugin-worker bootstrap over two createPortPairs, so worker placement is exercised without a thread. createTestLoader's default workers. 01 §5 same
tarball(files, prefix?) (files: Record<string, string | Uint8Array>, prefix = "package/") => Uint8Array A ustar tarball of regular files under prefix (npm's layout): fixtures for npm:/url: installs. 01 §3.6 same
pluginPackage(id, bb?, pkg?) (id: string, bb: JsonObject = {}, pkg: JsonObject = {}) => TestPlugin["packageJson"] A test package.json: @get-bb/plugin-<id> 1.0.0 with bb filled from the minimum (id, name, description, server: "./src/server.ts") plus your overrides. 01 §2.2 same

9.8 The four sketches

Trimmed from as-built §5; the full versions are examples/plugins/hello-slot/src/{server,app,host,contracts}.test.ts.

Server tier, both placements (server.test.ts):

import { createTestPlugin } from "@get-bb/plugin-sdk/testing";
const manifest = { id: "hello-slot", provides: { "hello-slot/greeter": { version: "1.0.0" } },
  contributes: { database: true, cli: { commands: ["hello"] }, hostRoles: [{ role: "rpc", name: "echo" }] } };
describe.each(["inproc", "worker"] as const)("hello-slot/greeter (%s)", (isolation) => {
  let plugin: TestPluginHandle;
  beforeEach(async () => { plugin = await createTestPlugin({ manifest, server, host, contracts: [greeter], isolation }); });
  afterEach(() => plugin.dispose());
  it("greets, then reloads", async () => {
    const svc = await plugin.inject(greeter);
    await expect(svc.greet({ name: "bb" })).resolves.toMatchObject({ count: 0 });
    await plugin.reload();
    await expect(svc.greet({ name: "bb" })).rejects.toMatchObject({ code: "stale_handle" });
  });
});

App tier over a fake core (app.test.ts):

import { builtinEnvironments } from "vitest/environments";
import { createAppHarness, fakeService } from "@get-bb/plugin-sdk/testing";
const dom = await builtinEnvironments["happy-dom"].setup(globalThis, {});
afterAll(() => dom.teardown(globalThis));
const harness = await createAppHarness({
  plugins: [{ id: "hello-slot", app }],
  path: "/hello",
  routes: { "/api/v1/hello-slot/greeter/greet": (input) => greeting(nameOf(input), count) },
  services: [fakeService(greeter, { greet: async ({ name }) => greeting(name, count), count: …, hostGreet: … })],
});
await harness.act(() => harness.socket.event("hello-slot/greeted", { name: "cli", count: 7 }));
await waitFor(harness, () => harness.element.querySelector('[data-testid="hello-slot-count"]')?.textContent === "count 7"); // §9.3: after the 50 ms debounce

Host tier without a process (host.test.ts):

import { fakeRoleContext } from "@get-bb/plugin-sdk/testing";
const ctx = fakeRoleContext({ pluginId: "hello-slot", role: "environment-provider", name: "scratch", dataDir });
const handle = await scratch.provision({ envId: "env_1", options: {}, source, signal, progress }, ctx.context);
expect(handle).toMatchObject({ kind: "local-path" });
expect(ctx.signals).toEqual([]);

Derived surfaces (contracts.test.ts):

import { expectDerivedSurfaces } from "@get-bb/plugin-sdk/testing";
it("passes the derived-surface conformance check (03 §7.4)", () => {
  expectDerivedSurfaces(greeter, {
    greet: { input: { name: "bb" }, output: sample },
    count: { input: { name: "bb" }, output: sample },
    hostGreet: { input: { name: "bb" }, output: hostSample },
  });
});

9.9 Types index

Type Defined in See
TestPluginSpec, CreateTestPluginInit, TestPluginHandle packages/plugin-sdk/src/testing/server.ts 9.2
AppPluginSpec, AppHarnessInit, AppHarness, FakeService packages/plugin-sdk/src/testing/app.ts 9.3, 9.4
FakeRoleContextInit, FakeRoleContext, HostCommandCaller packages/plugin-sdk/src/testing/host.ts 9.5
DerivedSurfaceSamples packages/kernel-contract/src/testing/index.ts 9.6
TestLoaderInit, TestLoader, TestPlugin, LoadPluginInit, LoadedPlugin packages/kernel-loader/src/testing/index.ts 9.7