Skip to content

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

ts
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

CallFires
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

ts
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 denied

The 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:

ts
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

ts
const harness = createPluginTestHarness(server, { permissions: [] });   // declare nothing
await harness.start();
expect(harness.denials).toContain('world.entities');   // your spawn was denied, like live

What the harness deliberately does differently

For determinism, three live-host behaviors are simplified - none of them change your logic's correctness:

  1. Entity/KV updates apply immediately (no 10 Hz batching or rate-deferral).
  2. There is no tick budget or circuit breaker.
  3. 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).

VibeLands Creator - Plugin SDK apiVersion 2