Testing SDK Reference
ts
import { createPluginTestHarness } from '@vibelands/plugin-sdk/testing';Headless harness for plugin server entries. Mirrors the live host's quotas and permission gating with deterministic, in-memory infrastructure. Works with any test runner (the scaffold wires vitest).
createPluginTestHarness(hooks, options?)
ts
const harness = createPluginTestHarness(server, {
pluginId: 'my-treasure', // default 'test-plugin'
permissions: ['world.entities', 'storage', 'players.modify'], // default: ALL
config: { chestCount: 4 }, // raw config
configSchema: manifest.configSchema, // optional - resolves config like the host
world: {
worldId: 'test-world',
terrainSeed: 4242,
terrainSize: 600,
seaLevel: 2,
biome: 'grassland',
timeOfDay: 0.5,
weather: { type: 'clear', intensity: 0 },
getGroundHeight: (x, z) => 6, // your terrain stub
},
storage: { opened: { 'chest-1': 123 } }, // pre-seeded persistence ("previous session")
verbose: false, // echo ctx.log to console
});Pass your real manifest's permissions + configSchema to test exactly what ships.
Lifecycle drivers
| Method | Effect |
|---|---|
await harness.start() | runs onWorldStart |
harness.join(init) | adds a player, runs onPlayerJoin; init: { sessionId, name?, userId?, level?, mode?, dead?, position? } |
harness.leave(sessionId) | removes the player, runs onPlayerLeave |
harness.updatePlayer(sessionId, patch) | mutates player state (position/dead/...) without firing hooks |
await harness.message(type, data, sessionId) | runs the onMessage[type] handler (throws if missing or payload > 8 KB) |
harness.tick(dtSeconds = 0.1) | runs onTick once |
harness.advanceTimers(ms) | advances the virtual clock; fires due ctx.schedule timers in order |
await harness.dispose() | runs onDispose, cancels timers |
Inspection
| Member | Contains |
|---|---|
harness.ctx | the full PluginServerContext - poke any API directly |
harness.entities | ctx.entities (list/has/count - what clients would see) |
harness.kv() | parsed replicated KV blob |
harness.storageDump() | persistent storage as plain JSON |
harness.sent / sentOfType(type, sessionId?) | direct messages: { sessionId, type, data } |
harness.broadcasts / broadcastsOfType(type) | broadcasts: { type, data } |
harness.rewards | { sessionId, moneyCents, xp, label } per grant |
harness.logs | every ctx.log(...) call's args |
harness.denials | permission ids that were denied |
What's enforced like production
- entity quota (384) and
propertiessize (2 KB) -spawn/updatereturnnull/false - KV blob size (8 KB), storage value (32 KB) and total (2 MB) quotas
- permission gating with identical deny semantics (logged no-op + recorded in
denials) - message payload cap (8 KB) - the harness throws, since the host would silently drop
- entity id suffix rules, config resolution, reward amount bounds
What's intentionally different
| Live host | Harness | Why |
|---|---|---|
| entity transforms batch at 10 Hz, KV defers over 2/s | applied immediately | deterministic assertions |
| tick budget + circuit breaker | none | tests measure logic, not wall-clock |
| hook errors contained, plugin disabled | errors rethrow | tests should fail loudly |
ctx.schedule uses real timers | virtual clock via advanceTimers() | no sleeps in tests |
One real-world note: Date.now() inside your plugin is not virtualized. Make wall-clock durations (respawn minutes etc.) configurable and test with small values.
Recipes
Restart persistence:
ts
const second = createPluginTestHarness(server, { storage: first.storageDump(), ... });Permission regression:
ts
const harness = createPluginTestHarness(server, { permissions: [] });
await harness.start();
expect(harness.denials).toContain('world.entities');Scheduled behavior:
ts
await harness.start(); // plugin set a 60 s announcement interval
harness.advanceTimers(60_000);
expect(harness.broadcastsOfType('announcement')).toHaveLength(1);