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"] +}