@jamunlabs/gameu-session-test
v0.2.0
Published
Cross-language full-stack session test harness for gameu — spawns the real Rust host as a child process, opens real WebSocket clients per controller, drives the real game reducer in-process. Mocks live only at OS-level boundaries. Symmetric companion to t
Readme
@jamunlabs/gameu-session-test
Cross-language full-stack session test harness for gameu. Symmetric
companion to the Rust crate gameu-session-test — same mental model,
exposed for tests written in TypeScript / Node.
What it does
- Spawns the real
gameu-desktophost as a child process (random port). - Opens real WebSocket clients per controller against
/ws/controller. - Loads the real game reducer module in this Node process and bridges
Bootstrap/Input/PeerJoined/ … through/ws/tv. - Asserts on the reducer's in-memory
modeldirectly — no DOM, no rendering layer, no mocked transports.
The only seams that are mocked are OS-level boundaries: TCP loopback,
HTTP catalog responses, filesystem paths for the per-test data dir.
Everything else — LobbyEngine, LobbyDriver, codec, bridge schema,
the SDK, the published reducer — is the same code that ships.
Install
npm install --save-dev @jamunlabs/gameu-session-testThe harness needs to find a gameu-desktop binary. It searches in this
order:
process.env.GAMEU_HOST_BINARY— explicit override.<repo>/target/release/gameu-desktopwalking up from cwd.@jamunlabs/gameu-host-<platform>/bin/gameu-desktop(npm package).
For local development inside the gameu workspace, run
cargo build --release -p gameu-desktop once and tier 2 picks the
binary up. External game repos add the right host-binary npm package
as a dev-dependency and tier 3 resolves it.
Minimal example
import { test, before, after } from "node:test";
import { SessionFixture } from "@jamunlabs/gameu-session-test";
import assert from "node:assert/strict";
let fix;
before(async () => {
fix = await SessionFixture.boot({
preInstalledGame: { id: "my-game", dir: "/abs/path/to/my-game" },
});
});
after(() => fix?.teardown());
test("game flow", async () => {
const alice = await fix.pairPhone("alice"); // first paired = master
const bob = await fix.pairPhone("bob");
await alice.awaitLobby((s) => s.peers.length === 2);
const game = await fix.startGame({
reducerPath: "/abs/path/to/my-game/reducer.js",
reducerExportName: "MyReducer",
});
alice.pickerNavigate("right");
alice.pickerSelect();
await alice.awaitLobby((s) => s.screen?.kind === "in_game");
await game.awaitModel((m) => m.board !== undefined);
alice.send({ kind: "input", event: "button_down", button: "cell-0", t_ms: 0 });
const after = await game.awaitModel((m) => m.board[0] !== "");
assert.equal(after.board[0], "x");
await game.close();
});Why plain node --test, not tsx
The harness's own sources are .ts, but tests written by consumers
(game repos) should be plain .mjs (or .test.ts compiled separately).
tsx --test misresolves the SDK's package.json exports field — it
treats ESM as CJS and flattens named exports to a default-only shape,
which breaks the reducers' import { Reducer, msg, EFFECT_KINDS, ... }
from "@jamunlabs/gameu-sdk". Plain node --test honors ESM correctly.
For tests inside this workspace, see
crates/gameu-runtime/tests-js-session/ — those run via plain
node --test from cargo xtask test behavior.
API surface
SessionFixture.boot(opts)— spawn host, wait for bound port.catalogUrl?: catalog URL the host fetches at boot. Empty string = empty catalog.hostBinary?: tier-1 override.preInstalledGame?:{id, dir}— recursively copies the game directory into<dataDir>/games/<id>/so it appears in the picker as a regular Installed tile (skips the install pipeline; gameplay tests get straight to the action).
fix.pairPhone(name)→PairedController— fresh credential.fix.reconnectWithSessionToken(name, token)— reconnect path.fix.startGame({reducerPath, reducerExportName?})→RunningGame— connects to/ws/tvand bridges Bootstrap / Input / PeerJoined / PeerReconnected / RequestEnd to the reducer.fix.teardown()— kill child + remove temp data dir.RunningGame.awaitModel(pred, timeoutMs?)— block until predicate matches the latest reducer model, or throw on timeout.RunningGame.eventLog/effectLog— every event the bridge fed the reducer / every effect the reducer emitted. Useful for asserting on dispatch patterns rather than just final state.
