Testing Your Plugin
@vibelands/plugin-sdk/testing runs your server entry headlessly: in-memory entities, storage, players, messages and virtual timers, with the same quotas and permission gating as the live host. No game server, no database, millisecond tests.
Tier B scaffolds (vibelands create --tier B) come pre-wired: npm install && npm test.
The harness in one look
import { describe, expect, it } from 'vitest';
import { createPluginTestHarness } from '@vibelands/plugin-sdk/testing';
import { server } from './index';
it('pays a nearby player who opens a chest', async () => {
const harness = createPluginTestHarness(server, {
permissions: ['world.entities', 'storage', 'players.modify'],
config: { chestCount: 4, rewardCents: 2500 },
world: { terrainSeed: 4242, getGroundHeight: () => 6 },
});
await harness.start(); // onWorldStart
const chest = harness.entities.list()[0];
harness.join({ sessionId: 'p1', name: 'Finder', position: chest.position });
await harness.message('openChest', { chestId: chest.id.split(':')[1] }, 'p1');
expect(harness.entities.has(chest.id)).toBe(false);
expect(harness.rewards).toEqual([
{ sessionId: 'p1', moneyCents: 2500, xp: 0, label: 'Treasure found' },
]);
expect(harness.broadcastsOfType('chestOpened')).toHaveLength(1);
});Driving the lifecycle
| Call | Fires |
|---|---|
await harness.start() | onWorldStart |
harness.join({ sessionId, name, position, dead?, ... }) | onPlayerJoin |
harness.updatePlayer('p1', { position, dead }) | nothing - mutates state between messages |
await harness.message(type, data, sessionId) | the matching onMessage handler |
harness.tick(dtSeconds) | onTick |
harness.advanceTimers(ms) | due ctx.schedule timers, deterministically |
await harness.dispose() | onDispose |
Asserting outcomes
harness.entities.list() / .has(id) / .count() // replicated entities
harness.kv() // ctx.state blob (scoreboards)
harness.storageDump() // persistent storage as plain JSON
harness.sent / harness.sentOfType('welcome', 'p1')
harness.broadcasts / harness.broadcastsOfType('chestOpened')
harness.rewards // [{ sessionId, moneyCents, xp, label }]
harness.logs // everything ctx.log() printed
harness.denials // permissions that were deniedThe restart test - your persistence proof
Seed a fresh harness with the previous one's storage. If your plugin loads its saved state correctly, this passes:
it('keeps opened chests closed across a restart', async () => {
const first = createHarness();
await first.start();
// ...open one chest...
const second = createHarness({ storage: first.storageDump() }); // ← "restart"
await second.start();
expect(second.entities.count()).toBe(3); // one stays closed
});Testing permission failures
const harness = createPluginTestHarness(server, { permissions: [] }); // declare nothing
await harness.start();
expect(harness.denials).toContain('world.entities'); // your spawn was denied, like liveWhat the harness deliberately does differently
For determinism, three live-host behaviors are simplified - none of them change your logic's correctness:
- Entity/KV updates apply immediately (no 10 Hz batching or rate-deferral).
- There is no tick budget or circuit breaker.
- Hook errors are rethrown so tests fail loudly instead of being contained.
And one honest limitation: Date.now() is real. harness.advanceTimers() drives ctx.schedule timers, not wall-clock math - if your logic compares Date.now() deltas (respawn timers), make the durations configurable and test with small values.
Testing the client side
Client surfaces are plain React Three Fiber components - unit-test your pure helpers normally, and validate the rendered result in the remote sandbox (browser console: window.__VIBELANDS_PLUGINS__.status(), scene assertions via stable name props).