Testing
A portal game is tested at four levels, from fastest to most faithful. Each one catches what the one before cannot, and the last one is exactly what the review runs.
| Level | Command | Takes | What it proves |
|---|---|---|---|
| Unit tests | pnpm --filter @gameportal/game-<id> test | seconds | Your rules, flow, save format, pacing, manifest and score bounds. No browser. |
| Dev console | pnpm --filter @gameportal/game-<id> dev | live | The game inside a fake portal on a second origin, with a message log and buttons for everything the portal can say. |
validate | pnpm --filter @gameportal/game-<id> validate | seconds | The static rules over the build output: manifest, files, sizes, code patterns. |
check | pnpm --filter @gameportal/game-<id> check | 3 to 4 minutes | validate plus the fake-portal runner: 15 scenarios in a real browser. |
Unit tests #
pnpm test runs Vitest. The starter's tests show the habits worth copying:
Rules in pure code. Keep the game's rules (
world.ts,run.ts) free of the DOM, the clock andMath.random, and test them without a browser: determinism by seed, scoring, combos, the end of a round.Saves. Anything parses (
null, arrays, wrong types); merging two saves loses nothing; an old format upgrades; a newer one is refused.Pacing with a fake clock, including the burst that must not happen when a timer fires late.
Manifest against constants. The manifest and your constants say some things twice (id, version, event and score names, plausibility numbers). Test that they agree: they drift apart silently otherwise.
A plausibility proof. Derive
maxPerSecondfrom your rules and hold it against the fastest bot you can write.
The manifest and the bounds, in a test:
import fs from "node:fs";
import { expect, it } from "vitest";
import { gameManifestSchema, isEventAllowed, isScoreModeAllowed, measureJson, MAX_SAVE_BYTES } from "@gameportal/sdk-types";
const manifest = gameManifestSchema.parse(JSON.parse(fs.readFileSync("manifest.json", "utf8")));
// the most points the fastest possible player can earn per second, derived from the game's rules
const MAX_POINTS_PER_SECOND = 55;
it("sends only what the manifest declares", () => {
expect(isScoreModeAllowed(manifest, { mode: "round_score", value: 240 })).toBe(true);
expect(isEventAllowed(manifest, { name: "round_end", props: { score: 240, hits: 18 } })).toBe(true);
expect(isEventAllowed(manifest, { name: "undeclared_event" })).toBe(false);
});
it("bounds what a perfect player can score, with a margin of at most 2x", () => {
const rule = manifest.scores["round_score"];
expect(rule?.maxPerSecond).toBeGreaterThanOrEqual(MAX_POINTS_PER_SECOND);
expect(rule?.maxPerSecond).toBeLessThanOrEqual(MAX_POINTS_PER_SECOND * 2);
});
it("keeps a long game's save far below the limit", () => {
const veryLongGame = { coins: 9_999_999, items: Array.from({ length: 2000 }, (_, id) => ({ id, level: 99 })) };
const size = measureJson(veryLongGame, MAX_SAVE_BYTES);
expect(size.ok && size.bytes < MAX_SAVE_BYTES / 2).toBe(true);
});To test code that uses the bridge, give createBridge a fake window and clock (the window, now and timing options exist for this). A game opened on its own, for example:
import { expect, it, vi } from "vitest";
import { createBridge } from "@gameportal/game-bridge";
it("falls back to standalone mode after 2 seconds outside an iframe", async () => {
vi.useFakeTimers();
const store = new Map<string, string>();
const win = {
parent: undefined as unknown,
addEventListener() {},
removeEventListener() {},
localStorage: { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => void store.set(key, value) },
};
win.parent = win; // a top-level page: its parent is itself
const bridge = createBridge({ gameId: "my-game", gameVersion: "1.0.0", saveVersion: 1, window: win });
bridge.start();
await vi.advanceTimersByTimeAsync(2000);
expect(bridge.mode).toBe("standalone");
bridge.storage.save({ coins: 3 });
expect(JSON.parse(store.get("my-game:save") ?? "null")).toEqual({ coins: 3 });
bridge.destroy();
vi.useRealTimers();
});The bridge's own tests (packages/game-bridge/test/bridge.test.ts) fake a whole portal with a real MessageChannel; they are the template for tests of the portal-mode behaviour.
The dev console and the fake portal #
pnpm dev starts your game and a fake portal on two origins (http://localhost:3760/ and http://127.0.0.1:4160/<id>/<version>/). The game is served by the real games-host request handler; the portal side is the same script the runner drives (packages/rules/assets/portal.js), limits, acks, revisions and conflicts included. Options: --portal-port, --games-port, --no-build (serve the existing dist/).
| I want to see ... | Use |
|---|---|
hello, init and ready, and how long they took | The status line at the top and the message log. |
| A new player, a returning player, a save from a newer build, a broken save | Edit the init payload (or pick a preset) and press "Apply and reload game". |
The game coping with a late or missing init | "Delay init", untick "answer hello automatically", "Answer hello now". |
| An expired session | "Re-init over the port". |
| Pause, resume, a language change, a new day | The buttons under "Messages to the game". |
That a pause sent as a plain window message changes nothing | "Pause as window message". |
The game's reaction to rate_limited, network, too_large, invalid, unauthorized or conflict | "Answers to the game": queue one for the next save, score or event. |
| What the portal does with my events and scores | The log: undeclared events and modes get invalid; a score over the manifest's limits is acknowledged but noted as suspicious, as the server stores it. |
| The daily challenge | The state panel: progress of the manifest's metric as the portal would count it. |
| Standalone mode | "Open the game on its own": the same address as a top-level page. |
| Other frame sizes | The size menu (the portal's fullscreen layer resizes the frame while the game runs). |
| What a missing limit would hide | Untick "enforce the SDK limits". |
Click a log row for the full message; "Copy as JSON" copies the log for a bug report.
The test hook #
The runner plays a game better when it can ask it things. Expose window.<camelCaseId> (for my-game: window.myGame) with two parts:
getState()returns a flat object. The runner readsmode(portalorstandalone),pausedandlang. Keep it free of anything that changes by itself (elapsed time, frame counts): the runner compares it with the device clock a day ahead and flags differences.test.start(),test.autoplay(on),test.forfeit()andtest.advance(ms): start a round, let a bot play, give up, and runmsof game time at once. They let the runner produce scores and events, grow the save and prove the manifest's declarations.
interface TestHook {
getState(): Record<string, string | number | boolean>;
test: { start(): void; autoplay(on: boolean): void; forfeit(): void; advance(ms: number): void };
}
declare const game: { mode: string; paused: boolean; lang: string; start(): void; setBot(on: boolean): void; forfeit(): void; step(ms: number): void };
(window as unknown as { myGame: TestHook }).myGame = {
getState: () => ({ mode: game.mode, paused: game.paused, lang: game.lang }),
test: {
start: () => game.start(),
autoplay: (on) => game.setBot(on),
forfeit: () => game.forfeit(),
advance: (ms) => game.step(ms),
},
};A game without the hook is still tested, with random touches and keys; the score and event rules then stay manual for a reviewer.
Keep the hook out of the build players get: with it, anyone can let the bot play or skip time from the browser console. The starter guards the assignment with if (import.meta.env.VITE_GP_TEST_HOOKS === "1"), which Vite folds at build time: pnpm build and validate leave the hook out, pnpm dev and check build with VITE_GP_TEST_HOOKS=1. Run pnpm build again after check, before you submit.
The rules runner #
validate and check run @gameportal/rules, the same tool the review uses, with the same exit codes. A game made from the starter has them as scripts; for any other build folder run the CLI directly:
pnpm --filter @gameportal/rules validate path/to/build # static validators
pnpm --filter @gameportal/rules run path/to/build # static validators + the fake-portal runner<build> is the built game with its manifest.json next to index.html.
| Option | Meaning |
|---|---|
--level 0|1|2 | The integration level to check against. Default: detected from the build and the manifest. |
--out <file> | The JSON report path. Default rules-report/<id>.<command>.json. |
--json | Print the JSON report to stdout instead of the summary. |
--verbose | Also list manual and pending rules in the summary. |
--fail-on-warning | Exit 1 on warnings too. |
--archive <dir> | Check the release archive for immutability and a non-decreasing saveVersion. |
--error-window <ms> | (check) How long to watch for errors. Default 60000, the platform's rule. |
--startup-runs <n> | (check) Clean starts for the ready-time percentile. Default 3. |
--only <a,b> | (check) Run only these scenarios (names below). |
--portal-port, --games-port | (check) The two local ports (3750 and 4176 by default). |
--artifacts <dir> | (check) Where screenshots go. |
The scenarios you can name with --only: startup, main, standalone, late-init, reload, unsupported, saves, newer-save, acks, hostile, mobile, reduced-motion, throttled, clock-shift, hooked. What each does is in Platform rules.
Exit codes: 0 no blocker failed, 1 a blocker failed (or a warning, with --fail-on-warning), 2 bad usage or input, 3 the tool itself failed.
The report is JSON keyed by rule id (rules["R-SDK-03"]). Every rule has a status, the result of each layer (static, runtime), its evidence (findings: level, message, file and line or numbers) and needsHumanReview. Statuses: pass, warn, fail, manual (automation could not decide, a person looks), skipped (the rule does not apply at this level) and pending (the layer that verifies it was not run; validate leaves the runtime rules pending). Finding levels: violation (a blocker becomes fail, a warning becomes warn), review (a pattern match or something automation could not decide; the rule becomes warn and a person looks) and note (evidence that changes nothing). The report records the rules version it ran with.
Screenshots of the runs are written next to the report in rules-report/<id>-artifacts/.
In CI #
The runner needs a Chromium. Run unit tests first (fast feedback), then the rules. A GitHub Actions job could look like this (adapt the paths to your repository):
name: game
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm --filter @gameportal/game-my-game test
- run: pnpm --filter @gameportal/game-my-game check
- uses: actions/upload-artifact@v4
if: always()
with:
name: rules-report
path: games/my-game/rules-reportcheckexits non-zero when a blocker fails, so the job fails by itself. Add--fail-on-warningif you want warnings to fail too.Jobs on the same machine share ports: give each its own
--portal-portand--games-port.The 60 second error window is part of the platform's rule: keep it in CI, shorten it only for local iteration.
Keep the JSON report as a build artifact: the reviewer reads the same format.