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 required → needs-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):
setupis synchronous. The harness runs it through the collector to build the descriptor the kernel compares against at load (07 §5.9); asetupthat returns a Promise is refused withinvalid_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 synchronoussetuppasses all three.- Root-scoped children only. A root occupant cannot declare a
thread/panechild (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.tsinstalls the DOM at module scope withconst dom = await builtinEnvironments["happy-dom"].setup(globalThis, {})and tears it down inafterAll, instead of 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.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 debounceHost 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 |