9. Tests and scripts

101 tests, ~2.5 seconds. That speed is the payoff of keeping src/game/ free of Socket.IO, Express and Prisma.

SuiteCountNeeds
tests/unit/game.test.js38nothing
tests/unit/lobbyManager.test.js20nothing
tests/integration/gameFlow.test.js25real sockets + Postgres
tests/integration/reconnect.test.js9real sockets
tests/integration/collectRace.test.js5real sockets
tests/integration/isolation.test.js4real sockets
npm test                  # everything
npm run test:unit         # pure logic — no server, no DB, no browser
npm run test:integration  # real sockets + real Postgres, --runInBand

Jest with plain ESM

jest.config.js sets transform: {}no Babel, no transform step at all. The npm script is:

node --experimental-vm-modules node_modules/jest/bin/jest.js

rather than NODE_OPTIONS=... jest, because inline env-var syntax does not work in npm scripts on Windows (cmd.exe). This form is cross-platform and avoids needing cross-env.

Globals are not injected under ESM, so every test imports them explicitly:

import { describe, test, expect, beforeEach } from '@jest/globals';

Jest runs each test file in its own worker process, so the lobbyManager singleton is naturally isolated per file. Integration tests additionally use --runInBand because they open real ports and share one database.


Unit tests

No server, no database, no browser — direct function calls.

function makeGame({ collectibleCount = 3 } = {}) {
  return createGame({ code: 'TEST01', collectibleCount, rng: () => 0.5, now: 1000 });
}

A fixed rng and a fixed now make every run identical: same spawn positions, same timestamps, no flakiness.

game.test.js covers

  • The contested collect — two players on one orb, exactly one scores, and alice.score + bob.score === 1
  • Rejections: out of range, exactly at the edge (accepted), unknown ids, already-collected, finished game
  • Orb count clamping — 5,000 becomes 75, garbage becomes the floor
  • Movement: proportional to elapsed time, diagonal not faster, oversized vectors capped, short vectors preserved, garbage ignored, clamped at all four walls
  • Username uniqueness: case-insensitive, whitespace-insensitive, stays claimed while disconnected, released on removal and on grace expiry
  • Disconnect/reconnect: score and slot survive, dropped players stop sliding, grace expiry drops them
  • Leaderboard ordering and tie-breaks
  • Wire payloads: full state includes orbs, the 20 Hz snapshot does not, positions are integers, and the reconnect token never appears in any broadcast

lobbyManager.test.js covers

Code generation (500 codes, zero collisions), case-insensitive lookup, capacity, and all three quickPlay rules — including never seating a player in a lobby whose players have all disconnected.

Also lobby isolation at the logic level: collecting in lobby A leaves lobby B untouched, and both lobbies hand out p0 without interfering because a token from one is worthless in the other.


Integration tests

tests/helpers/testServer.js boots a real server on a random free port (listen(0)) and connects real socket.io-client clients.

export async function startTestServer() { ... }   // { url, manager, io, broadcaster, connect, close }
export function once(socket, event, timeoutMs)    // next event as a promise
export function emit(socket, event, payload)      // emit-with-ack as a promise
export function joinGame(socket, payload)         // ack + the 'joined' payload
export function placeOn(game, playerId, id)       // teleport, via server memory
export function waitFor(check, opts)              // poll until truthy

Because the test runs in the same process as the server, it can reach into manager.games and place a player exactly on an orb — making distance a controlled constant rather than the thing under test.

collectRace.test.js — the headline

Two clients, then a full lobby of ten, all firing collect for the same orb via Promise.all:

expect(results.filter((r) => r.ok)).toHaveLength(1);
expect(results.filter((r) => r.reason === 'ALREADY_COLLECTED')).toHaveLength(9);
expect(totalScoreInLobby).toBe(1);

The race is proved twice: deterministically at the function level in the unit test, and through real sockets here.

isolation.test.js — the rooms proof

A client in lobby B records every event it receives with onAny() while lobby A is made as loud as possible. Then:

expect(collected).toHaveLength(0);
expect(everything).not.toContain(lobbyA.id);
expect(everything).not.toContain('alice');
for (const { payload } of snapshots) expect(payload.players).toHaveLength(1);

Asserting on a recording of everything is stronger than asserting a specific event did not arrive — it catches leaks you did not think to look for.

reconnect.test.js

Score/position/rank survive a dropped socket; a stale or foreign token falls back to a normal join rather than erroring; others see player:left with reason DISCONNECTED and the player stays on the leaderboard as connected: false.

Grace expiry is tested by calling tickGame with a future clock — instant, rather than waiting 30 real seconds.

One test taught a lesson worth keeping: both listeners must be attached before the disconnect, because the server emits player:left and leaderboard back to back in the same turn. Subscribing to the second one afterwards always misses it.

gameFlow.test.js

Join validation, capacity, username uniqueness across lobbies, the ghost-lobby matchmaking rule, movement, garbage-input resistance, /health, HTTP 404 shape, and the persistence path: finishing a game writes one Game, N GameParticipants and the PlayerStat upserts — verified against the real database with waitFor, since persistence is intentionally not awaited.

It also covers "Play again": a socket can join a new game immediately after its game ends, no stale room membership remains, and every player can replay — not just the winner.

Cleanup runs in afterAll, deleting the games and usernames the file created.


scripts/stress.js

Headless socket.io-client bots that behave like browsers: seek the nearest orb, send input at 20 Hz, request a collect when close.

node scripts/stress.js --lobbies=50 --players=10 --duration=30
FlagDefaultMeaning
--lobbies50Lobbies to create
--players10Bots per lobby
--duration30Seconds
--ramp8Milliseconds between lobby batches
--urllocalhost:3000Target
--shardLabel for running several processes

Lobby recycling

A lobby of 10 bots clears 40 orbs in seconds, so games end mid-run. Rather than inflating the orb count to an unplayable density, finished lobbies are recycled: bots leave and join a freshly created lobby over the same socket.

That keeps the target lobby count live for the whole run, hammers the join/leave paths continuously, and — importantly on Windows — opens no new TCP connections, so the client cannot exhaust its ephemeral port range.

recycleSlot is guarded by a recycling flag because all ten bots receive game:over and only one new lobby should be created for them.

What it measures

Client side: connections, joins, recycles, unexpected disconnects, state messages/sec, collect attempts won vs lost, and ack latency percentiles.

Server side: it polls /health for tick p50/p95, heap and RSS — so both sides appear in one report and you can tell which is the bottleneck rather than guessing.

The correctness line is the important one:

orbs in finished games 28640  ->  all accounted for ✓
cross-lobby leakage    none ✓

Points awarded always equals orbs consumed, never more.

Reading the results honestly

At ~1,000 sockets the load generator is often the bottleneck, not the server — one Node process parsing 18,000 messages/sec on the same machine as the server. Compare client-observed latency against the server's own tick timings before blaming the server. Run in stages (1 → 10 → 50 → 100 lobbies) so a failure tells you where it broke.


scripts/seed.js and scripts/clean-test-data.js

npm run db:seed writes 12 plausible finished games so the all-time leaderboard is not empty on a fresh clone. It deliberately uses the same one-transaction-per-game shape as production code, including the raw ON CONFLICT upsert.

npm run db:clean deletes rows whose username starts with bot or e2e_. A stress run plays hundreds of real games, and Postgres cannot tell those from real players — they end up on the all-time leaderboard. Run this before demoing.

Built with LogoFlowershow