Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Headless tests

A game’s tests run in Node. There, the devtools store and the harness step the world, draw nothing and load no asset.

In Node, the store comes from @spawnite/engine/devtools and the harness from @spawnite/engine/core, so nothing draws and no asset loads. A game with its own systems passes its plugins to createHeadlessGame as plugins, the same list it gives Game.

The following file makes a scene of three coins as a spawn function, steps it five seconds, and reads the dump. step(5) is five seconds of game time. The step fixture of a test, which Pin it in a test describes, counts fixed steps instead, so its step(5) is five steps, 1/12 s:

packages/engine/test/outside/headlessCoins.ts
import type { World } from "koota";
import {
createHeadlessGame,
PickupTrait,
requireAuthority,
spawn,
} from "@spawnite/engine/core";
import { attachDevtools, useDevtools } from "@spawnite/engine/devtools";
/** Three coins in a row ahead of the spawn: the scene, as a function that
* spawns into the world. */
export function spawnCoins(world: World) {
for (const x of [-2, 0, 2])
spawn(requireAuthority(world), {
position: [x, 0.5, -4],
traits: [PickupTrait({ reward: 1 })],
});
}
/** Steps the scene five seconds with nothing drawn, and returns its dump
* and its world, which the caller destroys once done. */
export async function stepCoins() {
const game = await createHeadlessGame({ scene: spawnCoins });
const detach = attachDevtools(game);
const { step, dump } = useDevtools.getState();
step(5);
const { entities, hash } = dump();
detach();
return { entities, hash, world: game.world };
}

A scene’s own component mounts in Node too, through the engine’s headless mount, on a root that draws nothing. The views that load a model, a sound or a VRM, the camera, and Hud, Panel and Modal render nothing headless, and the traits the rest fill are what the steps run on. A game’s own component that loads a file or reaches the page reads useHeadless() from the engine and renders nothing when it is true, as the engine’s do. One that does not fails the mount with an error that names it, such as Rock failed headless: Failed to parse URL from /@fs/rock.glb, followed by that fix.

useQualityStore() works headless too: each mount builds its own stores, which start empty and keep nothing once it ends. A headless mount saves nothing; spawnite simulate --save <file> starts the world from a save file. The mount makes a world from no plugins, the core alone, unless it is handed one, so a scene that places a character or a behaviour mounts on a world made from the game’s plugins. A scene that calls useLevels() mounts on the levels the last defineLevels call among its imports made, as Game’s levels gives them, unless the world handed to the mount holds levels already.

The components render between the calls that step the world, not between the steps, so a modal that opens on a step, a round screen for one, holds none of them up: read the round’s state off the dump. The mount returns once every render and effect it started has run, a first load the scene waits on included. In React’s development build it waits under React’s act. In its production build, which has no act, it waits until fiber’s scheduler is idle and no update waits to commit. A room run with NODE_ENV=production mounts a scene this way.

import { createGameWorld, mountHeadlessScene } from "@spawnite/engine";
import { attachDevtools, useDevtools } from "@spawnite/engine/devtools";
import { plugins } from "./game";
import { Run } from "./scenes/Run";
// On the game's own plugins: a world made from none runs the core alone.
const game = await mountHeadlessScene(Run, createGameWorld(plugins));
const detach = attachDevtools(game);
useDevtools.getState().step(5);
const { entities, hash } = useDevtools.getState().dump();
detach();
await game.unmount();
game.world.destroy();

The cli’s spawnite simulate --scene <name> and the MCP’s simulate tool mount the component src/scenes/<Name>.tsx exports under that name this way, loading it and the engine through the game’s own Vite. They step the world on the plugins list the scene’s file exports, else on the one src/game.ts exports, else on the core alone. The world takes the levels from the last defineLevels call among the scene’s imports, where there is one, as Game’s levels gives them. They step it for seconds of game time or until an expression holds, with keys held through the game’s own key table, as its page reads them. Then they print this dump with its hash, the plugins the world was made from, each with its description, and the step’s systems, one line per phase.

A test that renders a game’s whole app, Game and its DOM together, runs in jsdom, which has no WebGL. With GAME_ENGINE_VIEWS=off in the environment, the engine leaves out the views that load a model or draw the world: a World’s ground, scatter, props, navmesh and light, a Player’s body, footsteps and marker, an Entity’s model and an InstancedModel. The screen, every Hud and Panel, the game’s own DOM and every trait still mount, so the test can find a button and read the wallet. The test still mocks @react-three/fiber and @react-three/drei with the fakes @spawnite/testing/fiber exports, as the example’s test/app.test.tsx does:

vi.mock("@react-three/fiber", async () =>
(await import("@spawnite/testing/fiber")).fakeFiber(),
);
vi.mock("@react-three/drei", async (importOriginal) =>
(await import("@spawnite/testing/fiber")).fakeDrei(importOriginal),
);

fakeFiber() draws the canvas as a div with data-testid="canvas" that holds its children, with the camera prop as data-camera, and its useThree reads a detached canvas, a 1280 by 720 size and an always-running loop. fakeFiber({ state }) puts what state() returns behind useThree’s get, read at each call, so a case can swap the renderer. fakeDrei(importOriginal, overrides) keeps drei whole, draws no CameraControls or GradientTexture, and replaces any export overrides names, such as Html. fakeLoader(importOriginal, () => model) keeps the real fiber and hands every useLoader call the loaded value, for a test that draws with the real renderer. The vi.mock call stays in each test file, because Vitest hoists it only there.

The engine reads the variable on each render rather than once when its module loads, so a test sets it in beforeEach, after its imports have loaded the engine, and the package’s bundle reads it the same way as the source does:

beforeEach(() => vi.stubEnv("GAME_ENGINE_VIEWS", "off"));

A page has no process, so the variable changes nothing in a game’s build.

The vitest config names a setup file in setupFiles. The setup tells React the tests run under act, unmounts what the last test rendered, registers the jest-dom matchers, and stubs the scrollTo and matchMedia that jsdom lacks, answering matchMedia as a desktop. A game names @spawnite/testing/setup there, with @spawnite/testing as a dev dependency, and adds @spawnite/testing/setup to types in its test tsconfig to type the matchers. A game that needs more keeps a test/setup.ts, lists it after the package, and sets sequence: { setupFiles: "list" } so vitest runs the two in that order rather than in parallel. A game made by spawnite create from a template that declares @spawnite/testing comes with all of it; Holdfast’s template keeps its own test/setup.ts instead.

A test of code that fetches, such as a panel that reads a page from the dev server, stubs fetch with stubFetch from @spawnite/testing/fetch. It takes one answer for every URL, or an answer per URL, keyed by the URL exactly as the code passes it. An answer is a Response, served as a fresh copy on each call, or a function that takes fetch’s own arguments. It returns the spy, so the test reads the calls, and it puts the real fetch back when the test ends:

import { stubFetch } from "@spawnite/testing/fetch";
it("saves the shot through the dev server", async () => {
const fetch = stubFetch({
"/__shots": Response.json({ file: "shots/1.png" }),
});
await expect(saveShot(png)).resolves.toBe("shots/1.png");
expect(fetch).toHaveBeenCalledWith("/__shots", expect.anything());
});

A URL that no route names rejects, and fails the test even where the code catches the rejection, so stubFetch({}) proves the code fetched nothing. A function answer covers the rest: a network failure, () => Promise.reject(new TypeError("offline")), or a request held in flight, () => new Promise(() => {}). Call it in the test or in a beforeEach.

The mocked canvas hands the engine a renderer that cannot compile shaders ahead, and the engine reads that as a canvas that runs no frames. The loading screen then waits on nothing a frame does, neither the room’s welcome nor a drawn frame, and with the views off it waits on no drawn character or ground. It lifts once the physics, navmesh and scatter shapes chunks have landed and the scene has mounted, so the test finds the lobby’s Play button and every Hud.

A test that writes files, such as a save or a generated game, makes its folder with makeTempDir from @spawnite/testing/temp and never removes it:

import { makeTempDir } from "@spawnite/testing/temp";
it("writes the save", () => {
const folder = makeTempDir("save-");
// ...
});

Called in a test or a beforeEach, the folder goes when that test finishes, passed or failed. Called at a file’s or a describe block’s top level, it goes when that file or block finishes, or with the file when the block is skipped or filtered out. A file whose every test a -t filter leaves out runs no hook, so the temp guard names what its top level made. Not in a beforeAll, whose folder nothing removes: call it at the block’s top level instead. A second argument names a parent folder other than os.tmpdir(), for a test whose files must sit inside the workspace.

@spawnite/testing/model writes a rigged figure as a GLB, for a test that needs a model file: boxes skinned to named bones, in metres with y up, and one clip that turns one bone from its rest pose:

packages/testing/test/model.test.ts (part)
/** A two-bone figure: a box a bone, and a clip that bends the spine. */
const figure: WriteFigureOptions = {
bones: [
{ name: "Hips", head: [0, 0.8, 0] },
{ name: "Spine", head: [0, 0.9, 0], parent: "Hips" },
],
boxes: [
{ bone: "Hips", centre: [0, 0.85, 0], size: [0.3, 0.1, 0.2] },
{ bone: "Spine", centre: [0, 1.1, 0], size: [0.3, 0.4, 0.2] },
],
clip: {
name: "bend",
bone: "Spine",
seconds: 1,
rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2],
},
};

await writeFigure(file, figure) writes the file, with writeFigure and the WriteFigureOptions type imported from @spawnite/testing/model.

A bone’s parent comes before it, and rotation is a quaternion, x, y, z, w.

A property test states what holds for every input, and fast-check draws the inputs: hundreds of them, biased toward the edges such as 0, -0, the smallest double and a __proto__ key. When one fails, fast-check shrinks it to the smallest input that still fails and prints it. Write one where a rule is short and the inputs are many: a round trip, such as a decode of an encode or a save read back after a write, or a law, such as a sine that is odd.

Add fast-check and @fast-check/vitest as dev dependencies, and pass a fixed seed and numRuns, so every machine draws the same inputs:

import { fc, test } from "@fast-check/vitest";
import { expect } from "vitest";
import { quaternionToYaw, yawToQuaternion } from "@spawnite/engine";
test.prop([fc.double({ min: -3.14, max: 3.14, noNaN: true })], {
seed: 2640,
numRuns: 200,
})("reads back the yaw it wrote", (yaw) => {
expect(quaternionToYaw(yawToQuaternion(yaw))).toBeCloseTo(yaw, 12);
});

A failure prints the counterexample, with the seed and path that replay it alone. The engine’s own property tests sit beside its other tests, such as the codec’s in packages/engine/test/gameplay/replication/codec.test.ts.