-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat(server): WS load-test harness, e2e suite, and Bun runtime support #4977
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,238 @@ | ||
| // End-to-end test of the game server over real HTTP + WebSocket: boots the | ||
| // actual master + cluster workers (Node via tsx, or Bun with | ||
| // E2E_RUNTIME=bun) and drives the full lobby -> start -> turn-relay -> | ||
| // rejoin -> kick flow that production clients exercise. | ||
|
|
||
| import { randomUUID } from "crypto"; | ||
| import { afterAll, beforeAll, describe, expect, test } from "vitest"; | ||
| import { | ||
| createGame, | ||
| gameInfo, | ||
| RUNTIME, | ||
| sleep, | ||
| TestClient, | ||
| TestServer, | ||
| waitFor, | ||
| } from "./util"; | ||
|
|
||
| describe(`game server e2e (runtime: ${RUNTIME})`, () => { | ||
| const server = new TestServer(); | ||
| const creatorToken = randomUUID(); | ||
| let game: { gameID: string; workerIndex: number; port: number }; | ||
| let creator: TestClient; | ||
| let playerB: TestClient; | ||
| let playerC: TestClient; | ||
|
|
||
| beforeAll(async () => { | ||
| await server.start(); | ||
| game = await createGame(creatorToken); | ||
| creator = new TestClient(game.port, game.gameID, "creator", creatorToken); | ||
| playerB = new TestClient(game.port, game.gameID, "playerB"); | ||
| playerC = new TestClient(game.port, game.gameID, "playerC"); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| for (const c of [creator, playerB, playerC]) c?.close(); | ||
| await server.stop(); | ||
| }); | ||
|
|
||
| test("health endpoint reports ok once workers are ready", async () => { | ||
| const res = await fetch("http://127.0.0.1:3000/api/health"); | ||
| expect(res.ok).toBe(true); | ||
| expect(await res.json()).toEqual({ status: "ok" }); | ||
| }); | ||
|
|
||
| test("create_game requires an auth token", async () => { | ||
| const res = await fetch(`http://127.0.0.1:${game.port}/api/create_game`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| }); | ||
| expect(res.status).toBe(400); | ||
| }); | ||
|
|
||
| test("created game is queryable on its worker", async () => { | ||
| const info = await gameInfo(game.port, game.gameID); | ||
| expect(info).not.toBeNull(); | ||
| expect(info.gameID).toBe(game.gameID); | ||
| expect(info.gameConfig.gameType).toBe("Private"); | ||
| }); | ||
|
|
||
| test("clients join over WebSocket and get server-assigned clientIDs", async () => { | ||
| await creator.join(); | ||
| await playerB.join(); | ||
| await playerC.join(); | ||
| expect(creator.clientID).toBeTruthy(); | ||
| expect(playerB.clientID).toBeTruthy(); | ||
| expect(playerC.clientID).toBeTruthy(); | ||
| // All three ids are distinct. | ||
| expect( | ||
| new Set([creator.clientID, playerB.clientID, playerC.clientID]).size, | ||
| ).toBe(3); | ||
|
|
||
| // Lobby info converges to 3 clients for everyone. | ||
| await waitFor( | ||
| async () => { | ||
| const info = await gameInfo(game.port, game.gameID); | ||
| return info?.clients?.length === 3; | ||
| }, | ||
| 10_000, | ||
| "lobby to report 3 clients", | ||
| ); | ||
| }); | ||
|
|
||
| test("lobby identifies the creator", async () => { | ||
| const info = await gameInfo(game.port, game.gameID); | ||
| expect(info.lobbyCreatorClientID).toBe(creator.clientID); | ||
| }); | ||
|
|
||
| test("non-creator cannot start the game", async () => { | ||
| playerB.sendIntent({ type: "toggle_game_start_timer" }); | ||
| await sleep(1500); | ||
| const info = await gameInfo(game.port, game.gameID); | ||
| expect(info.startsAt ?? undefined).toBeUndefined(); | ||
| }); | ||
|
|
||
| test("creator starts the game; every client receives prestart and start", async () => { | ||
| creator.sendIntent({ type: "toggle_game_start_timer" }); | ||
| for (const c of [creator, playerB, playerC]) { | ||
| await c.waitForMessage((m) => m.type === "prestart", 15_000); | ||
| const start = (await c.waitForMessage( | ||
| (m) => m.type === "start", | ||
| 15_000, | ||
| )) as any; | ||
| expect(start.gameStartInfo.gameID).toBe(game.gameID); | ||
| expect(start.gameStartInfo.players).toHaveLength(3); | ||
| expect(start.myClientID).toBe(c.clientID); | ||
| const usernames = start.gameStartInfo.players.map((p: any) => p.username); | ||
| expect(usernames).toEqual( | ||
| expect.arrayContaining(["creator", "playerB", "playerC"]), | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| test("server broadcasts turns at the 100ms tick", async () => { | ||
| const before = creator.turns().length; | ||
| await sleep(1200); | ||
| const after = creator.turns().length; | ||
| const gained = after - before; | ||
| // ~12 expected; allow generous slack for CI jitter. | ||
| expect(gained).toBeGreaterThanOrEqual(8); | ||
| expect(gained).toBeLessThanOrEqual(16); | ||
| // Turn numbers are consecutive. | ||
| const numbers = creator.turns().map((t) => t.turn.turnNumber); | ||
| for (let i = 1; i < numbers.length; i++) { | ||
| expect(numbers[i]).toBe(numbers[i - 1] + 1); | ||
| } | ||
| }); | ||
|
|
||
| test("an intent is relayed to every client, stamped with the sender's clientID", async () => { | ||
| const marker = 987654321; // distinctive troops value to find the intent | ||
| playerB.sendIntent({ type: "attack", targetID: null, troops: marker }); | ||
| for (const c of [creator, playerB, playerC]) { | ||
| const turnMsg = (await c.waitForMessage( | ||
| (m) => | ||
| m.type === "turn" && | ||
| (m as any).turn.intents.some((i: any) => i.troops === marker), | ||
| 5_000, | ||
| )) as any; | ||
| const intent = turnMsg.turn.intents.find((i: any) => i.troops === marker); | ||
| // The clientID comes from the authenticated connection, not the payload. | ||
| expect(intent.clientID).toBe(playerB.clientID); | ||
| } | ||
| }); | ||
|
|
||
| test("a client that vanishes can rejoin and receives the missed turns", async () => { | ||
| // Hard-drop C's socket (no close frame ≈ network loss). | ||
| playerC.ws!.terminate(); | ||
| await sleep(600); | ||
| const lastTurn = | ||
| playerC.turns().length === 0 | ||
| ? 0 | ||
| : playerC.turns()[playerC.turns().length - 1].turn.turnNumber + 1; | ||
|
|
||
| const rejoined = new TestClient( | ||
| game.port, | ||
| game.gameID, | ||
| "playerC", | ||
| playerC.token, | ||
| ); | ||
| await rejoined.connect(); | ||
| rejoined.send({ | ||
| type: "rejoin", | ||
| gameID: game.gameID, | ||
| lastTurn, | ||
| token: playerC.token, | ||
| }); | ||
| const start = (await rejoined.waitForMessage( | ||
| (m) => m.type === "start", | ||
| 10_000, | ||
| )) as any; | ||
| // The catch-up slice starts exactly where the client left off. | ||
| expect(start.gameStartInfo.gameID).toBe(game.gameID); | ||
| if (start.turns.length > 0) { | ||
| expect(start.turns[0].turnNumber).toBe(lastTurn); | ||
| } | ||
| // Same identity as before the drop. | ||
| expect(start.myClientID).toBe(playerC.clientID); | ||
| playerC = rejoined; | ||
| playerC.clientID = start.myClientID; | ||
| }); | ||
|
|
||
| test("a client sending garbage is kicked with a reason", async () => { | ||
| const victim = new TestClient(game.port, game.gameID, "victim"); | ||
| await victim.join(); | ||
| victim.ws!.send("this is not json"); | ||
| await victim.waitForMessage( | ||
| (m) => m.type === "error" && (m as any).error.includes("invalid_message"), | ||
| 5_000, | ||
| ); | ||
| await waitFor( | ||
| () => victim.closeCode !== null, | ||
| 5_000, | ||
| "victim socket to close", | ||
| ); | ||
| expect(victim.closeCode).toBe(1000); | ||
|
|
||
| // Kicked identity cannot rejoin. | ||
| const comeback = new TestClient( | ||
| game.port, | ||
| game.gameID, | ||
| "victim", | ||
| victim.token, | ||
| ); | ||
| await comeback.connect(); | ||
| comeback.send({ | ||
| type: "join", | ||
| token: comeback.token, | ||
| gameID: game.gameID, | ||
| username: "victim", | ||
| clanTag: null, | ||
| turnstileToken: null, | ||
| }); | ||
| await waitFor( | ||
| () => comeback.closeCode !== null, | ||
| 5_000, | ||
| "kicked rejoin to be rejected", | ||
| ); | ||
| expect(comeback.closeCode).toBe(1002); | ||
| }); | ||
|
|
||
| test("games on the wrong worker are rejected", async () => { | ||
| // Join a game that lives on worker A via worker B's port: the message | ||
| // is dropped (no lobby_info ever arrives). | ||
| const wrongPort = game.port === 3001 ? 3002 : 3001; | ||
| const lost = new TestClient(wrongPort, game.gameID, "lostsoul"); | ||
| await lost.connect(); | ||
| lost.send({ | ||
| type: "join", | ||
| token: lost.token, | ||
| gameID: game.gameID, | ||
| username: "lostsoul", | ||
| clanTag: null, | ||
| turnstileToken: null, | ||
| }); | ||
| await sleep(1500); | ||
| expect(lost.messages.find((m) => m.type === "lobby_info")).toBeUndefined(); | ||
| lost.close(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| // Regression test for the cluster worker lifecycle: when the master dies, | ||
| // every worker must exit too. Node's cluster does this implicitly; Bun's | ||
| // does not, which orphaned workers that kept their ports bound (via | ||
| // SO_REUSEPORT) and served stale state next to a restarted server. Worker.ts | ||
| // now exits explicitly on IPC disconnect — this test pins that behavior on | ||
| // both runtimes. | ||
|
|
||
| import { afterAll, beforeAll, describe, expect, test } from "vitest"; | ||
| import { RUNTIME, TestServer, waitFor } from "./util"; | ||
|
|
||
| function pidAlive(pid: number): boolean { | ||
| try { | ||
| process.kill(pid, 0); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| describe(`worker lifecycle (runtime: ${RUNTIME})`, () => { | ||
| const server = new TestServer(); | ||
|
|
||
| beforeAll(async () => { | ||
| await server.start(); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await server.stop(); | ||
| }); | ||
|
|
||
| test("workers exit when the master is killed", async () => { | ||
| const workerPids = server.workerPids(); | ||
| expect(workerPids.length).toBeGreaterThanOrEqual(2); | ||
| for (const pid of workerPids) { | ||
| expect(pidAlive(pid)).toBe(true); | ||
| } | ||
|
|
||
| const masterPid = server.masterPid(); | ||
| expect(masterPid).not.toBeNull(); | ||
| // SIGKILL: the harshest case — no signal handler can run, only the IPC | ||
| // channel closing tells the workers their master is gone. | ||
| process.kill(masterPid!, "SIGKILL"); | ||
|
|
||
| await waitFor( | ||
| () => workerPids.every((pid) => !pidAlive(pid)), | ||
| 10_000, | ||
| `workers ${workerPids.join(",")} to exit after master SIGKILL`, | ||
| ); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Drop the upper bound on the turn count.
The lower bound checks the real property: the server keeps broadcasting turns. The upper bound checks how accurately this test process wakes from
sleep(1200). Under CI load the sleep can overrun by hundreds of milliseconds while the server keeps ticking, sogainedexceeds 16 and the test fails for a reason unrelated to the server. Measure the elapsed time and derive the expectation from it, or assert only the lower bound.💚 Proposed fix
test("server broadcasts turns at the 100ms tick", async () => { const before = creator.turns().length; + const t0 = Date.now(); await sleep(1200); + const elapsed = Date.now() - t0; const after = creator.turns().length; const gained = after - before; - // ~12 expected; allow generous slack for CI jitter. - expect(gained).toBeGreaterThanOrEqual(8); - expect(gained).toBeLessThanOrEqual(16); + // ~1 turn per 100ms. Scale the bound to the time we actually slept, so a + // slow test process does not fail the server. + expect(gained).toBeGreaterThanOrEqual(8); + expect(gained).toBeLessThanOrEqual(Math.ceil(elapsed / 100) + 4);📝 Committable suggestion
🤖 Prompt for AI Agents