Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ jobs:
- run: npm install

- run: npx tsc --noEmit

- run: npx tsc -p tsconfig.test.json
20 changes: 20 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
41 changes: 25 additions & 16 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
146 changes: 146 additions & 0 deletions test/dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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<string, unknown> | 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);
});
});
1 change: 1 addition & 0 deletions test/fixtures/chat.joined.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"chat.joined","cid":"7","payload":{"channel_id":"01j8x000000000000000000005"}}
1 change: 1 addition & 0 deletions test/fixtures/chat.left.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"chat.left","cid":"8","payload":{"channel_id":"01j8x000000000000000000005"}}
1 change: 1 addition & 0 deletions test/fixtures/chat.message.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"chat.message","payload":{"channel_id":"01j8x000000000000000000005","sender_id":"01j8x000000000000000000000","content":"hello","ts":1730000000000}}
1 change: 1 addition & 0 deletions test/fixtures/dm.message.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"dm.message","payload":{"sender_id":"01j8x000000000000000000002","content":"hi","ts":1730000000000}}
1 change: 1 addition & 0 deletions test/fixtures/dm.sent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"dm.sent","cid":"9","payload":{"recipient_id":"01j8x000000000000000000002","ts":1730000000000}}
1 change: 1 addition & 0 deletions test/fixtures/error.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"error","payload":{"reason":"invalid_message"}}
1 change: 1 addition & 0 deletions test/fixtures/match.finished.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.finished","payload":{"match_id":"01j8x000000000000000000001","result":{"winner":"01j8x000000000000000000000"}}}
1 change: 1 addition & 0 deletions test/fixtures/match.joined.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.joined","cid":"3","payload":{"match_id":"01j8x000000000000000000001","mode":"demo","players":["01j8x000000000000000000000"]}}
1 change: 1 addition & 0 deletions test/fixtures/match.left.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.left","cid":"4","payload":{"success":true}}
1 change: 1 addition & 0 deletions test/fixtures/match.matched.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.matched","payload":{"match_id":"01j8x000000000000000000001","mode":"demo","players":["01j8x000000000000000000000","01j8x000000000000000000002"]}}
1 change: 1 addition & 0 deletions test/fixtures/match.matchmaker_expired.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.matchmaker_expired","payload":{"ticket_id":"01j8x000000000000000000003"}}
1 change: 1 addition & 0 deletions test/fixtures/match.matchmaker_failed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.matchmaker_failed","payload":{"reason":"no_game_module"}}
1 change: 1 addition & 0 deletions test/fixtures/match.state.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.state","payload":{"tick":7,"players":{"01j8x000000000000000000000":{"x":120,"y":80}}}}
1 change: 1 addition & 0 deletions test/fixtures/match.vote_result.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.vote_result","payload":{"vote_id":"01j8x000000000000000000004","winner":"a"}}
1 change: 1 addition & 0 deletions test/fixtures/match.vote_start.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.vote_start","payload":{"vote_id":"01j8x000000000000000000004","template":"map_pick","options":[{"id":"a","label":"Arena"}]}}
1 change: 1 addition & 0 deletions test/fixtures/match.vote_tally.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.vote_tally","payload":{"vote_id":"01j8x000000000000000000004","tally":{"a":1}}}
1 change: 1 addition & 0 deletions test/fixtures/match.vote_vetoed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.vote_vetoed","payload":{"vote_id":"01j8x000000000000000000004"}}
1 change: 1 addition & 0 deletions test/fixtures/matchmaker.queued.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"matchmaker.queued","cid":"5","payload":{"ticket_id":"01j8x000000000000000000003","status":"pending"}}
1 change: 1 addition & 0 deletions test/fixtures/matchmaker.removed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"matchmaker.removed","cid":"6","payload":{"success":true}}
1 change: 1 addition & 0 deletions test/fixtures/notification.new.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"notification.new","payload":{"id":"01j8x000000000000000000006","kind":"friend_request","from":"01j8x000000000000000000002"}}
1 change: 1 addition & 0 deletions test/fixtures/presence.updated.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"presence.updated","cid":"10","payload":{"status":"online"}}
1 change: 1 addition & 0 deletions test/fixtures/session.connected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"session.connected","cid":"1","payload":{"player_id":"01j8x000000000000000000000"}}
1 change: 1 addition & 0 deletions test/fixtures/session.heartbeat.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"session.heartbeat","cid":"2","payload":{"ts":1730000000000}}
1 change: 1 addition & 0 deletions test/fixtures/vote.cast_ok.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"vote.cast_ok","cid":"11","payload":{"success":true}}
1 change: 1 addition & 0 deletions test/fixtures/vote.veto_ok.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"vote.veto_ok","cid":"12","payload":{"success":true}}
1 change: 1 addition & 0 deletions test/fixtures/world.finished.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.finished","payload":{"world_id":"01j8x000000000000000000007","result":{"winner":"01j8x000000000000000000000"}}}
1 change: 1 addition & 0 deletions test/fixtures/world.joined.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.joined","cid":"14","payload":{"world_id":"01j8x000000000000000000007","mode":"village"}}
1 change: 1 addition & 0 deletions test/fixtures/world.left.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.left","cid":"15","payload":{"success":true}}
1 change: 1 addition & 0 deletions test/fixtures/world.list.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.list","cid":"13","payload":{"worlds":[{"id":"01j8x000000000000000000007","mode":"village","capacity":16,"size":3}]}}
1 change: 1 addition & 0 deletions test/fixtures/world.phase_changed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.phase_changed","payload":{"phase":"event","started_at":1730000000000}}
1 change: 1 addition & 0 deletions test/fixtures/world.terrain.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.terrain","payload":{"coords":[0,0],"data":""}}
1 change: 1 addition & 0 deletions test/fixtures/world.tick.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.tick","payload":{"tick":42,"updates":[{"id":"01j8x000000000000000000000","op":"a","x":120,"y":80}]}}
9 changes: 9 additions & 0 deletions tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"types": ["node"]
},
"include": ["src", "test", "smoke_tests"]
}
Loading