From 122c2661c73324a5da9940e9f121d1d5822bdc77 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 1 May 2026 23:32:41 +0200 Subject: [PATCH] feat(test): protocol dispatch test against asobi fixture corpus Feeds the 32 canonical server-emitted message envelopes from the asobi protocol fixture corpus through AsobiWebSocket's message handler and asserts the matching listener fires. Catches doc-vs-server drift bugs (silent failures on unknown wire types) before they reach users. WsEventType updated to cover all 32 wire types. Adds: match.matched, match.finished, match.matchmaker_expired, match.matchmaker_failed, match.vote_start, match.vote_tally, match.vote_result, match.vote_vetoed, world.phase_changed, world.finished. Removes stale match.input (client-to-server only, server never emits). Test framework: vitest. Wired to a new Test workflow on Node 20. Lint workflow extended to type-check the test tree via tsconfig.test.json so WsEventType regressions fail at compile time. Fixtures live under test/fixtures and are excluded from the published package (files field already restricted to dist + src). --- .github/workflows/lint.yml | 2 + .github/workflows/test.yml | 20 +++ package.json | 7 +- src/types.ts | 41 +++--- test/dispatch.test.ts | 146 ++++++++++++++++++++ test/fixtures/chat.joined.json | 1 + test/fixtures/chat.left.json | 1 + test/fixtures/chat.message.json | 1 + test/fixtures/dm.message.json | 1 + test/fixtures/dm.sent.json | 1 + test/fixtures/error.json | 1 + test/fixtures/match.finished.json | 1 + test/fixtures/match.joined.json | 1 + test/fixtures/match.left.json | 1 + test/fixtures/match.matched.json | 1 + test/fixtures/match.matchmaker_expired.json | 1 + test/fixtures/match.matchmaker_failed.json | 1 + test/fixtures/match.state.json | 1 + test/fixtures/match.vote_result.json | 1 + test/fixtures/match.vote_start.json | 1 + test/fixtures/match.vote_tally.json | 1 + test/fixtures/match.vote_vetoed.json | 1 + test/fixtures/matchmaker.queued.json | 1 + test/fixtures/matchmaker.removed.json | 1 + test/fixtures/notification.new.json | 1 + test/fixtures/presence.updated.json | 1 + test/fixtures/session.connected.json | 1 + test/fixtures/session.heartbeat.json | 1 + test/fixtures/vote.cast_ok.json | 1 + test/fixtures/vote.veto_ok.json | 1 + test/fixtures/world.finished.json | 1 + test/fixtures/world.joined.json | 1 + test/fixtures/world.left.json | 1 + test/fixtures/world.list.json | 1 + test/fixtures/world.phase_changed.json | 1 + test/fixtures/world.terrain.json | 1 + test/fixtures/world.tick.json | 1 + tsconfig.test.json | 9 ++ 38 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 test/dispatch.test.ts create mode 100644 test/fixtures/chat.joined.json create mode 100644 test/fixtures/chat.left.json create mode 100644 test/fixtures/chat.message.json create mode 100644 test/fixtures/dm.message.json create mode 100644 test/fixtures/dm.sent.json create mode 100644 test/fixtures/error.json create mode 100644 test/fixtures/match.finished.json create mode 100644 test/fixtures/match.joined.json create mode 100644 test/fixtures/match.left.json create mode 100644 test/fixtures/match.matched.json create mode 100644 test/fixtures/match.matchmaker_expired.json create mode 100644 test/fixtures/match.matchmaker_failed.json create mode 100644 test/fixtures/match.state.json create mode 100644 test/fixtures/match.vote_result.json create mode 100644 test/fixtures/match.vote_start.json create mode 100644 test/fixtures/match.vote_tally.json create mode 100644 test/fixtures/match.vote_vetoed.json create mode 100644 test/fixtures/matchmaker.queued.json create mode 100644 test/fixtures/matchmaker.removed.json create mode 100644 test/fixtures/notification.new.json create mode 100644 test/fixtures/presence.updated.json create mode 100644 test/fixtures/session.connected.json create mode 100644 test/fixtures/session.heartbeat.json create mode 100644 test/fixtures/vote.cast_ok.json create mode 100644 test/fixtures/vote.veto_ok.json create mode 100644 test/fixtures/world.finished.json create mode 100644 test/fixtures/world.joined.json create mode 100644 test/fixtures/world.left.json create mode 100644 test/fixtures/world.list.json create mode 100644 test/fixtures/world.phase_changed.json create mode 100644 test/fixtures/world.terrain.json create mode 100644 test/fixtures/world.tick.json create mode 100644 tsconfig.test.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 79d011f..42f6ae9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,3 +18,5 @@ jobs: - run: npm install - run: npx tsc --noEmit + + - run: npx tsc -p tsconfig.test.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8b3cfbc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +jobs: + dispatch: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - run: npm install + + - run: npm test diff --git a/package.json b/package.json index 54af788..ae6c228 100644 --- a/package.json +++ b/package.json @@ -37,11 +37,14 @@ "build": "tsc", "clean": "rm -rf dist", "prepare": "npm run build", - "smoke": "tsx smoke_tests/smoke.ts" + "smoke": "tsx smoke_tests/smoke.ts", + "test": "vitest run" }, "devDependencies": { + "@types/node": "^25.6.0", "tsx": "^4.21.0", - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "vitest": "^4.1.5" }, "engines": { "node": ">=22.0.0" diff --git a/src/types.ts b/src/types.ts index c0cbb45..d70e55a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -354,29 +354,38 @@ export interface WsMessage { } export type WsEventType = - | "session.connected" - | "session.heartbeat" - | "match.state" - | "match.joined" - | "match.left" - | "match.input" - | "matchmaker.queued" - | "matchmaker.removed" - | "chat.message" | "chat.joined" | "chat.left" + | "chat.message" | "dm.message" | "dm.sent" - | "world.list" - | "world.joined" - | "world.left" - | "world.tick" - | "world.terrain" - | "presence.updated" + | "error" + | "match.finished" + | "match.joined" + | "match.left" + | "match.matched" + | "match.matchmaker_expired" + | "match.matchmaker_failed" + | "match.state" + | "match.vote_result" + | "match.vote_start" + | "match.vote_tally" + | "match.vote_vetoed" + | "matchmaker.queued" + | "matchmaker.removed" | "notification.new" + | "presence.updated" + | "session.connected" + | "session.heartbeat" | "vote.cast_ok" | "vote.veto_ok" - | "error"; + | "world.finished" + | "world.joined" + | "world.left" + | "world.list" + | "world.phase_changed" + | "world.terrain" + | "world.tick"; export interface AsobiClientOptions { baseUrl: string; diff --git a/test/dispatch.test.ts b/test/dispatch.test.ts new file mode 100644 index 0000000..4383490 --- /dev/null +++ b/test/dispatch.test.ts @@ -0,0 +1,146 @@ +// Dispatch unit test: feeds every canonical fixture through the SDK's +// WebSocket message handler and asserts the right listener fires. +// +// Pure unit test — no network. Catches doc-vs-server drift bugs (e.g. +// server emits `match.matched` but SDK's WsEventType union or docs only +// mention `matchmaker.matched`) before any user reports a silent failure. + +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { AsobiWebSocket } from "../src/websocket.js"; + +const FIXTURE_DIR = join(__dirname, "fixtures"); + +// Canonical wire types emitted by the server. Mirrors the asobi protocol +// fixture corpus at asobi/priv/protocol/fixtures/. +const EXPECTED: ReadonlySet = new Set([ + "chat.joined", + "chat.left", + "chat.message", + "dm.message", + "dm.sent", + "error", + "match.finished", + "match.joined", + "match.left", + "match.matched", + "match.matchmaker_expired", + "match.matchmaker_failed", + "match.state", + "match.vote_result", + "match.vote_start", + "match.vote_tally", + "match.vote_vetoed", + "matchmaker.queued", + "matchmaker.removed", + "notification.new", + "presence.updated", + "session.connected", + "session.heartbeat", + "vote.cast_ok", + "vote.veto_ok", + "world.finished", + "world.joined", + "world.left", + "world.list", + "world.phase_changed", + "world.terrain", + "world.tick", +]); + +function listFixtureTypes(): string[] { + return readdirSync(FIXTURE_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")) + .sort(); +} + +function newClient(): AsobiWebSocket { + return new AsobiWebSocket({ url: "ws://example.invalid", token: "t" }); +} + +function feed(ws: AsobiWebSocket, raw: string): void { + // handleMessage is private; reach in for the test. Same pattern the + // sibling Lua/Defold SDK dispatch tests use. + (ws as unknown as { handleMessage: (raw: string) => void }).handleMessage( + raw, + ); +} + +describe("protocol dispatch", () => { + const fixtureTypes = listFixtureTypes(); + + it("has fixtures", () => { + expect(fixtureTypes.length).toBeGreaterThan(0); + }); + + it("every fixture has an EXPECTED entry", () => { + const missing = fixtureTypes.filter((t) => !EXPECTED.has(t)); + expect(missing, `fixtures with no EXPECTED entry: ${missing.join(", ")}`) + .toEqual([]); + }); + + it("every EXPECTED entry has a fixture", () => { + const have = new Set(fixtureTypes); + const missing = [...EXPECTED].filter((t) => !have.has(t)); + expect(missing, `EXPECTED entries with no fixture: ${missing.join(", ")}`) + .toEqual([]); + }); + + for (const mtype of fixtureTypes) { + it(`${mtype} -> on("${mtype}") fires`, () => { + const raw = readFileSync(join(FIXTURE_DIR, `${mtype}.json`), "utf8"); + const ws = newClient(); + let fired = false; + let received: Record | null = null; + ws.on(mtype, (payload) => { + fired = true; + received = payload; + }); + feed(ws, raw); + expect(fired, `listener for "${mtype}" did not fire`).toBe(true); + expect(received).not.toBeNull(); + }); + } + + it("WsEventType union covers every fixture (compile check)", () => { + // If a fixture's wire type is missing from WsEventType, the next + // line fails to compile. This is the autocomplete guarantee. + const _typed: import("../src/types.js").WsEventType[] = [ + "chat.joined", + "chat.left", + "chat.message", + "dm.message", + "dm.sent", + "error", + "match.finished", + "match.joined", + "match.left", + "match.matched", + "match.matchmaker_expired", + "match.matchmaker_failed", + "match.state", + "match.vote_result", + "match.vote_start", + "match.vote_tally", + "match.vote_vetoed", + "matchmaker.queued", + "matchmaker.removed", + "notification.new", + "presence.updated", + "session.connected", + "session.heartbeat", + "vote.cast_ok", + "vote.veto_ok", + "world.finished", + "world.joined", + "world.left", + "world.list", + "world.phase_changed", + "world.terrain", + "world.tick", + ]; + expect(_typed.length).toBe(EXPECTED.size); + }); +}); diff --git a/test/fixtures/chat.joined.json b/test/fixtures/chat.joined.json new file mode 100644 index 0000000..b3a1f77 --- /dev/null +++ b/test/fixtures/chat.joined.json @@ -0,0 +1 @@ +{"type":"chat.joined","cid":"7","payload":{"channel_id":"01j8x000000000000000000005"}} diff --git a/test/fixtures/chat.left.json b/test/fixtures/chat.left.json new file mode 100644 index 0000000..ef05147 --- /dev/null +++ b/test/fixtures/chat.left.json @@ -0,0 +1 @@ +{"type":"chat.left","cid":"8","payload":{"channel_id":"01j8x000000000000000000005"}} diff --git a/test/fixtures/chat.message.json b/test/fixtures/chat.message.json new file mode 100644 index 0000000..edb07b5 --- /dev/null +++ b/test/fixtures/chat.message.json @@ -0,0 +1 @@ +{"type":"chat.message","payload":{"channel_id":"01j8x000000000000000000005","sender_id":"01j8x000000000000000000000","content":"hello","ts":1730000000000}} diff --git a/test/fixtures/dm.message.json b/test/fixtures/dm.message.json new file mode 100644 index 0000000..7217128 --- /dev/null +++ b/test/fixtures/dm.message.json @@ -0,0 +1 @@ +{"type":"dm.message","payload":{"sender_id":"01j8x000000000000000000002","content":"hi","ts":1730000000000}} diff --git a/test/fixtures/dm.sent.json b/test/fixtures/dm.sent.json new file mode 100644 index 0000000..84071d2 --- /dev/null +++ b/test/fixtures/dm.sent.json @@ -0,0 +1 @@ +{"type":"dm.sent","cid":"9","payload":{"recipient_id":"01j8x000000000000000000002","ts":1730000000000}} diff --git a/test/fixtures/error.json b/test/fixtures/error.json new file mode 100644 index 0000000..aa8b5cf --- /dev/null +++ b/test/fixtures/error.json @@ -0,0 +1 @@ +{"type":"error","payload":{"reason":"invalid_message"}} diff --git a/test/fixtures/match.finished.json b/test/fixtures/match.finished.json new file mode 100644 index 0000000..15ad8dd --- /dev/null +++ b/test/fixtures/match.finished.json @@ -0,0 +1 @@ +{"type":"match.finished","payload":{"match_id":"01j8x000000000000000000001","result":{"winner":"01j8x000000000000000000000"}}} diff --git a/test/fixtures/match.joined.json b/test/fixtures/match.joined.json new file mode 100644 index 0000000..a9b7755 --- /dev/null +++ b/test/fixtures/match.joined.json @@ -0,0 +1 @@ +{"type":"match.joined","cid":"3","payload":{"match_id":"01j8x000000000000000000001","mode":"demo","players":["01j8x000000000000000000000"]}} diff --git a/test/fixtures/match.left.json b/test/fixtures/match.left.json new file mode 100644 index 0000000..9466845 --- /dev/null +++ b/test/fixtures/match.left.json @@ -0,0 +1 @@ +{"type":"match.left","cid":"4","payload":{"success":true}} diff --git a/test/fixtures/match.matched.json b/test/fixtures/match.matched.json new file mode 100644 index 0000000..6750684 --- /dev/null +++ b/test/fixtures/match.matched.json @@ -0,0 +1 @@ +{"type":"match.matched","payload":{"match_id":"01j8x000000000000000000001","mode":"demo","players":["01j8x000000000000000000000","01j8x000000000000000000002"]}} diff --git a/test/fixtures/match.matchmaker_expired.json b/test/fixtures/match.matchmaker_expired.json new file mode 100644 index 0000000..4e25f23 --- /dev/null +++ b/test/fixtures/match.matchmaker_expired.json @@ -0,0 +1 @@ +{"type":"match.matchmaker_expired","payload":{"ticket_id":"01j8x000000000000000000003"}} diff --git a/test/fixtures/match.matchmaker_failed.json b/test/fixtures/match.matchmaker_failed.json new file mode 100644 index 0000000..4ba71c7 --- /dev/null +++ b/test/fixtures/match.matchmaker_failed.json @@ -0,0 +1 @@ +{"type":"match.matchmaker_failed","payload":{"reason":"no_game_module"}} diff --git a/test/fixtures/match.state.json b/test/fixtures/match.state.json new file mode 100644 index 0000000..b7fd103 --- /dev/null +++ b/test/fixtures/match.state.json @@ -0,0 +1 @@ +{"type":"match.state","payload":{"tick":7,"players":{"01j8x000000000000000000000":{"x":120,"y":80}}}} diff --git a/test/fixtures/match.vote_result.json b/test/fixtures/match.vote_result.json new file mode 100644 index 0000000..11281a6 --- /dev/null +++ b/test/fixtures/match.vote_result.json @@ -0,0 +1 @@ +{"type":"match.vote_result","payload":{"vote_id":"01j8x000000000000000000004","winner":"a"}} diff --git a/test/fixtures/match.vote_start.json b/test/fixtures/match.vote_start.json new file mode 100644 index 0000000..869bb72 --- /dev/null +++ b/test/fixtures/match.vote_start.json @@ -0,0 +1 @@ +{"type":"match.vote_start","payload":{"vote_id":"01j8x000000000000000000004","template":"map_pick","options":[{"id":"a","label":"Arena"}]}} diff --git a/test/fixtures/match.vote_tally.json b/test/fixtures/match.vote_tally.json new file mode 100644 index 0000000..a7c5be7 --- /dev/null +++ b/test/fixtures/match.vote_tally.json @@ -0,0 +1 @@ +{"type":"match.vote_tally","payload":{"vote_id":"01j8x000000000000000000004","tally":{"a":1}}} diff --git a/test/fixtures/match.vote_vetoed.json b/test/fixtures/match.vote_vetoed.json new file mode 100644 index 0000000..f7806ff --- /dev/null +++ b/test/fixtures/match.vote_vetoed.json @@ -0,0 +1 @@ +{"type":"match.vote_vetoed","payload":{"vote_id":"01j8x000000000000000000004"}} diff --git a/test/fixtures/matchmaker.queued.json b/test/fixtures/matchmaker.queued.json new file mode 100644 index 0000000..a96541c --- /dev/null +++ b/test/fixtures/matchmaker.queued.json @@ -0,0 +1 @@ +{"type":"matchmaker.queued","cid":"5","payload":{"ticket_id":"01j8x000000000000000000003","status":"pending"}} diff --git a/test/fixtures/matchmaker.removed.json b/test/fixtures/matchmaker.removed.json new file mode 100644 index 0000000..49f9d93 --- /dev/null +++ b/test/fixtures/matchmaker.removed.json @@ -0,0 +1 @@ +{"type":"matchmaker.removed","cid":"6","payload":{"success":true}} diff --git a/test/fixtures/notification.new.json b/test/fixtures/notification.new.json new file mode 100644 index 0000000..61b2231 --- /dev/null +++ b/test/fixtures/notification.new.json @@ -0,0 +1 @@ +{"type":"notification.new","payload":{"id":"01j8x000000000000000000006","kind":"friend_request","from":"01j8x000000000000000000002"}} diff --git a/test/fixtures/presence.updated.json b/test/fixtures/presence.updated.json new file mode 100644 index 0000000..7dc9050 --- /dev/null +++ b/test/fixtures/presence.updated.json @@ -0,0 +1 @@ +{"type":"presence.updated","cid":"10","payload":{"status":"online"}} diff --git a/test/fixtures/session.connected.json b/test/fixtures/session.connected.json new file mode 100644 index 0000000..be8aa9c --- /dev/null +++ b/test/fixtures/session.connected.json @@ -0,0 +1 @@ +{"type":"session.connected","cid":"1","payload":{"player_id":"01j8x000000000000000000000"}} diff --git a/test/fixtures/session.heartbeat.json b/test/fixtures/session.heartbeat.json new file mode 100644 index 0000000..b82222d --- /dev/null +++ b/test/fixtures/session.heartbeat.json @@ -0,0 +1 @@ +{"type":"session.heartbeat","cid":"2","payload":{"ts":1730000000000}} diff --git a/test/fixtures/vote.cast_ok.json b/test/fixtures/vote.cast_ok.json new file mode 100644 index 0000000..60fb68a --- /dev/null +++ b/test/fixtures/vote.cast_ok.json @@ -0,0 +1 @@ +{"type":"vote.cast_ok","cid":"11","payload":{"success":true}} diff --git a/test/fixtures/vote.veto_ok.json b/test/fixtures/vote.veto_ok.json new file mode 100644 index 0000000..4321de6 --- /dev/null +++ b/test/fixtures/vote.veto_ok.json @@ -0,0 +1 @@ +{"type":"vote.veto_ok","cid":"12","payload":{"success":true}} diff --git a/test/fixtures/world.finished.json b/test/fixtures/world.finished.json new file mode 100644 index 0000000..5458593 --- /dev/null +++ b/test/fixtures/world.finished.json @@ -0,0 +1 @@ +{"type":"world.finished","payload":{"world_id":"01j8x000000000000000000007","result":{"winner":"01j8x000000000000000000000"}}} diff --git a/test/fixtures/world.joined.json b/test/fixtures/world.joined.json new file mode 100644 index 0000000..7017bf4 --- /dev/null +++ b/test/fixtures/world.joined.json @@ -0,0 +1 @@ +{"type":"world.joined","cid":"14","payload":{"world_id":"01j8x000000000000000000007","mode":"village"}} diff --git a/test/fixtures/world.left.json b/test/fixtures/world.left.json new file mode 100644 index 0000000..e717961 --- /dev/null +++ b/test/fixtures/world.left.json @@ -0,0 +1 @@ +{"type":"world.left","cid":"15","payload":{"success":true}} diff --git a/test/fixtures/world.list.json b/test/fixtures/world.list.json new file mode 100644 index 0000000..f064e87 --- /dev/null +++ b/test/fixtures/world.list.json @@ -0,0 +1 @@ +{"type":"world.list","cid":"13","payload":{"worlds":[{"id":"01j8x000000000000000000007","mode":"village","capacity":16,"size":3}]}} diff --git a/test/fixtures/world.phase_changed.json b/test/fixtures/world.phase_changed.json new file mode 100644 index 0000000..4e7b738 --- /dev/null +++ b/test/fixtures/world.phase_changed.json @@ -0,0 +1 @@ +{"type":"world.phase_changed","payload":{"phase":"event","started_at":1730000000000}} diff --git a/test/fixtures/world.terrain.json b/test/fixtures/world.terrain.json new file mode 100644 index 0000000..1903a91 --- /dev/null +++ b/test/fixtures/world.terrain.json @@ -0,0 +1 @@ +{"type":"world.terrain","payload":{"coords":[0,0],"data":""}} diff --git a/test/fixtures/world.tick.json b/test/fixtures/world.tick.json new file mode 100644 index 0000000..3778eca --- /dev/null +++ b/test/fixtures/world.tick.json @@ -0,0 +1 @@ +{"type":"world.tick","payload":{"tick":42,"updates":[{"id":"01j8x000000000000000000000","op":"a","x":120,"y":80}]}} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..ed734c2 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "types": ["node"] + }, + "include": ["src", "test", "smoke_tests"] +}