mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 15:10:40 +00:00
Handle matchmaking socket close codes per API contract (#4595)
## What Implements the close-code contract from the matchmaking API handoff (API PR #419) in the matchmaking modal, plus two runnable integration harnesses. Previously `onclose` only logged, so an API deploy while queued left the player on the "searching" spinner forever (the queue is in-memory on the API worker). | Close | Behavior | | --- | --- | | 1008 `Invalid session` | Reconnect and re-send `join` — `getPlayToken()` refreshes an expired token internally, so the rejoin carries a fresh JWT | | 1000 `Replaced by newer connection` | Another tab/window took the queue slot: show a message (new `matchmaking_modal.replaced` string) and stop. No retry | | Any other close before assignment | Server restart/deploy: reconnect and re-send `join`, with exponential backoff (1s doubling to a 15s cap) | Intentional closes (user backs out of the modal, assignment received) don't reconnect. A pending 2s join timer from a previous socket is cleared before each reconnect so it can't fire on the new socket. ## Test harnesses (`tests/matchmaking/`) - **`npm run test:matchmaking`** (contained): drives the real modal in a headless browser against a fake in-process matchmaking server speaking the documented protocol; covers the whole close-code table over real WebSockets. Needs only `npm run dev`. - **`npm run test:matchmaking:e2e`**: real integration against the API worker on `localhost:8787` — two browser players join the real queue, the dev game server receives the checkin assignment, and both clients end up in the same created game. ## Why now This is required by the matchmaking API's client contract independent of the upcoming 2v2 work ("clients must already handle unexpected close → reconnect and rejoin"). The rest of the 2v2 integration is planned for the next version. ## Verification - Contained harness: 8/8 checks pass (join, 1006 reconnect+rejoin, 1008 rejoin with fresh token, assignment, replaced → message + no retry, intentional close → no retry/no message). - E2E harness against a local worker: 3/3 — both players matched into the same game, game created via checkin and joinable by both. - `npm test` (all 2,046 pass), `npx tsc --noEmit`, ESLint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// Contained matchmaking integration test: drives the real matchmaking modal
|
||||
// in the real app against a fake matchmaking server (fakeServer.mjs) that
|
||||
// speaks the documented protocol. Covers the close-code contract:
|
||||
// - unexpected close (deploy) -> reconnect + rejoin
|
||||
// - 1008 Invalid session -> reconnect + rejoin (fresh token)
|
||||
// - 1000 Replaced by newer connection-> message shown, NO retry
|
||||
// - intentional close (user backs out or assignment received) -> no retry
|
||||
//
|
||||
// Prerequisite: the dev app must be running (`npm run dev`, port 9000).
|
||||
// Run: npm run test:matchmaking
|
||||
|
||||
import {
|
||||
gotoHome,
|
||||
launch,
|
||||
} from "../../.claude/skills/run-openfront/driver.mjs";
|
||||
import { startFakeMatchmakingServer } from "./fakeServer.mjs";
|
||||
import { isUp, makeChecker, waitFor } from "./util.mjs";
|
||||
|
||||
if (!(await isUp("http://localhost:9000"))) {
|
||||
console.error(
|
||||
"Dev app is not running on :9000 — start it with `npm run dev`.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fake = await startFakeMatchmakingServer();
|
||||
const control = async (path, body) => {
|
||||
const res = await fetch(`${fake.controlUrl}/${path}`, {
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return res.json();
|
||||
};
|
||||
const joins = async () => {
|
||||
const res = await fetch(`${fake.controlUrl}/state`);
|
||||
return (await res.json()).joins;
|
||||
};
|
||||
const joinCountReaches = (n, timeoutMs) =>
|
||||
waitFor(async () => (await joins()).length >= n, {
|
||||
timeoutMs,
|
||||
label: `join #${n} to reach the fake server`,
|
||||
});
|
||||
|
||||
const { browser, page } = await launch();
|
||||
const c = makeChecker();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
|
||||
// Redirect the modal's /matchmaking/join socket to the fake server while
|
||||
// keeping real browser WebSocket (and close-code) semantics.
|
||||
await page.evaluate((wsUrl) => {
|
||||
const Real = window.WebSocket;
|
||||
window.WebSocket = class extends Real {
|
||||
constructor(url, protocols) {
|
||||
const s = String(url);
|
||||
if (s.includes("/matchmaking/join")) {
|
||||
super(
|
||||
`${wsUrl}/matchmaking/join?${s.split("?")[1] ?? ""}`,
|
||||
protocols,
|
||||
);
|
||||
} else {
|
||||
super(url, protocols);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.__mmMessages = [];
|
||||
window.addEventListener("show-message", (e) =>
|
||||
window.__mmMessages.push(e.detail?.message),
|
||||
);
|
||||
}, fake.wsUrl);
|
||||
|
||||
const modal = (body) =>
|
||||
page.evaluate(`(() => {
|
||||
const el = document.querySelector("matchmaking-modal");
|
||||
${body}
|
||||
})()`);
|
||||
const resetAndConnect = () =>
|
||||
modal(`el.gameID = null;
|
||||
el.intentionalClose = false;
|
||||
el.reconnectAttempts = 0;
|
||||
el.connect();`);
|
||||
|
||||
// 1. Joining the queue: connect -> join arrives (after the modal's 2s delay)
|
||||
await resetAndConnect();
|
||||
await joinCountReaches(1, 8000);
|
||||
c.check("join sent after connect", true);
|
||||
|
||||
// 2. Deploy/restart: server drops the socket abruptly -> reconnect + rejoin
|
||||
await control("kill");
|
||||
await joinCountReaches(2, 10000);
|
||||
c.check("unexpected close -> reconnected and rejoined", true);
|
||||
|
||||
// 3. Invalid session: next join is closed 1008 -> client retries and rejoins
|
||||
await control("reject-next");
|
||||
await control("kill"); // forces the reconnect whose join gets 1008
|
||||
await joinCountReaches(4, 20000); // join 3 rejected, join 4 accepted
|
||||
c.check("1008 -> reconnected and rejoined with fresh token", true);
|
||||
|
||||
// 4. Assignment: modal records the gameId
|
||||
await control("assign", { gameId: "FakeGame1" });
|
||||
await waitFor(() => modal(`return el.gameID === "FakeGame1";`), {
|
||||
timeoutMs: 5000,
|
||||
label: "modal to receive match-assignment",
|
||||
});
|
||||
c.check("match-assignment received", true);
|
||||
await modal(`el.onClose();`); // stop the game-exists polling
|
||||
|
||||
// 5. Replaced by newer connection: message shown, no retry
|
||||
await resetAndConnect();
|
||||
await joinCountReaches(5, 8000);
|
||||
await control("replace");
|
||||
await waitFor(() => page.evaluate(() => window.__mmMessages.length > 0), {
|
||||
timeoutMs: 5000,
|
||||
label: "replaced message",
|
||||
});
|
||||
const msg = await page.evaluate(() => window.__mmMessages.at(-1));
|
||||
c.check(
|
||||
`replaced -> message shown ("${msg}")`,
|
||||
typeof msg === "string" && !msg.includes("matchmaking_modal."),
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
c.check("replaced -> no retry", (await joins()).length === 5);
|
||||
|
||||
// 6. Intentional close (user backs out): no retry, no message
|
||||
await resetAndConnect();
|
||||
await joinCountReaches(6, 8000);
|
||||
const msgsBefore = await page.evaluate(() => window.__mmMessages.length);
|
||||
await modal(`el.onClose();`);
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
c.check("intentional close -> no retry", (await joins()).length === 6);
|
||||
c.check(
|
||||
"intentional close -> no message",
|
||||
(await page.evaluate(() => window.__mmMessages.length)) === msgsBefore,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fake.close();
|
||||
}
|
||||
c.finish();
|
||||
Reference in New Issue
Block a user