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.

LevelCommandTakesWhat it proves
Unit testspnpm --filter @gameportal/game-<id> testsecondsYour rules, flow, save format, pacing, manifest and score bounds. No browser.
Dev consolepnpm --filter @gameportal/game-<id> devliveThe game inside a fake portal on a second origin, with a message log and buttons for everything the portal can say.
validatepnpm --filter @gameportal/game-<id> validatesecondsThe static rules over the build output: manifest, files, sizes, code patterns.
checkpnpm --filter @gameportal/game-<id> check3 to 4 minutesvalidate 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 and Math.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 maxPerSecond from your rules and hold it against the fastest bot you can write.

The manifest and the bounds, in a test:

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

ts
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 tookThe status line at the top and the message log.
A new player, a returning player, a save from a newer build, a broken saveEdit 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 dayThe 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 scoresThe 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 challengeThe 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 sizesThe size menu (the portal's fullscreen layer resizes the frame while the game runs).
What a missing limit would hideUntick "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 reads mode (portal or standalone), paused and lang. 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() and test.advance(ms): start a round, let a bot play, give up, and run ms of game time at once. They let the runner produce scores and events, grow the save and prove the manifest's declarations.

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

bash
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.

OptionMeaning
--level 0|1|2The 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.
--jsonPrint the JSON report to stdout instead of the summary.
--verboseAlso list manual and pending rules in the summary.
--fail-on-warningExit 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):

yaml
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-report
  • check exits non-zero when a blocker fails, so the job fails by itself. Add --fail-on-warning if you want warnings to fail too.

  • Jobs on the same machine share ports: give each its own --portal-port and --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.

İlerlemeni kaydet

E-postana bir bağlantı gönderelim ya da Google ile devam et. Misafir olarak kazandıkların hesabına taşınır.