Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
15 kB · 498 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499/** * The lobby's browser-side client: openLobby, connectLobby, deployLobby. * * The one place in this codebase that drives real code (not source-regex, * see lobby-screen.test.mjs) against a mocked network. A WebSocket carries * the live half of the lobby protocol — the shape of what travels over it * (tagged scenario/seats/deployed/chat events out, tagged * scenario/claimSeat/botSeat/invite/chat requests in) is exactly what a * regex over the source would miss if a callback got mis-wired to the wrong * tag. * * `import.meta.env` does not exist under plain Node, which is why api.ts * guards the two reads it makes of it at module scope with `?.` — see the * comment there. Nothing else about api.ts needed to change for this file * to import it directly. * * Run with `npm test`. */import { test, beforeEach } from "node:test";import assert from "node:assert/strict";
/** * A WebSocket stand-in: enough of the real interface for connectLobby to * drive (addEventListener, send, close, readyState, the OPEN constant) plus * a couple of test-only hooks (open(), receive()) to drive it back. */class MockWebSocket { static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
constructor(url) { this.url = url; this.readyState = MockWebSocket.CONNECTING; this.sent = []; this.listeners = new Map(); MockWebSocket.instances.push(this); }
addEventListener(type, callback) { const list = this.listeners.get(type) ?? []; list.push(callback); this.listeners.set(type, list); }
send(data) { // The real WebSocket throws here when not OPEN. connectLobby's own // send() exists to make sure that never happens, so hitting this in a // test is a real bug in that guard, not a test artifact. if (this.readyState !== MockWebSocket.OPEN) { throw new Error("send() on a socket that is not OPEN"); } this.sent.push(JSON.parse(data)); }
close() { this.readyState = MockWebSocket.CLOSED; for (const cb of this.listeners.get("close") ?? []) cb({}); }
/** Test hook: the handshake finishing - fires "open" the same way a real * WebSocket does, so connectLobby's own open listener (onOpen) is * exercised the same way "message" and "close" already are below. */ open() { this.readyState = MockWebSocket.OPEN; for (const cb of this.listeners.get("open") ?? []) cb({}); }
/** Test hook: the server ending the connection, or the upgrade itself * being refused - either way the browser's WebSocket only ever tells a * caller "close", never why. */ serverClose() { this.readyState = MockWebSocket.CLOSED; for (const cb of this.listeners.get("close") ?? []) cb({}); }
/** Test hook: one server -> client message. */ receive(message) { for (const cb of this.listeners.get("message") ?? []) { cb({ data: JSON.stringify(message) }); } }}MockWebSocket.instances = [];globalThis.WebSocket = MockWebSocket;
const { openLobby, deployLobby, connectLobby, ApiError } = await import("../src/api.ts");
/** The socket the most recent connectLobby() call opened. */function lastSocket() { return MockWebSocket.instances.at(-1);}
beforeEach(() => { MockWebSocket.instances.length = 0;});
// --- openLobby ---------------------------------------------------------
test("openLobby posts the scenario and resolves to the new lobby's id", async () => { let seen; globalThis.fetch = async (url, init) => { seen = { url: String(url), init }; return new Response(JSON.stringify({ matchId: "lobby-1" }), { status: 200, }); };
const id = await openLobby("TrainingScenarios/1-FirstRun.mms");
assert.equal(id, "lobby-1"); assert.equal(seen.url, "/api/matches/lobby"); assert.equal(seen.init.method, "POST"); assert.equal(seen.init.credentials, "include"); assert.deepEqual(JSON.parse(seen.init.body), { scenario: "TrainingScenarios/1-FirstRun.mms", });});
test("openLobby throws the server's message on failure", async () => { globalThis.fetch = async () => new Response( JSON.stringify({ message: "That scenario is not available." }), { status: 400 }, );
await assert.rejects( () => openLobby("nope.mms"), (err) => { assert.ok(err instanceof ApiError); assert.equal(err.message, "That scenario is not available."); return true; }, );});
// --- deployLobby ---------------------------------------------------------
test("deployLobby posts to /deploy and resolves to the new match's id", async () => { let seen; globalThis.fetch = async (url, init) => { seen = { url: String(url), init }; return new Response(JSON.stringify({ matchId: "match-9" }), { status: 200, }); };
const id = await deployLobby("lobby-1");
assert.equal(id, "match-9"); assert.equal(seen.url, "/api/matches/lobby-1/deploy"); assert.equal(seen.init.method, "POST");});
test("deployLobby passes through the server's message on a 400", async () => { globalThis.fetch = async () => new Response( JSON.stringify({ message: "Every slot must be claimed before deploying.", }), { status: 400 }, );
await assert.rejects( () => deployLobby("lobby-1"), (err) => { assert.ok(err instanceof ApiError); assert.equal(err.message, "Every slot must be claimed before deploying."); return true; }, );});
test("deployLobby passes through the server's message on a 503", async () => { globalThis.fetch = async () => new Response( JSON.stringify({ message: "Match launching is not configured here." }), { status: 503 }, );
await assert.rejects( () => deployLobby("lobby-1"), (err) => { assert.equal(err.message, "Match launching is not configured here."); return true; }, );});
test("deployLobby falls back to a generic message on a bare 409", async () => { // The double-deploy race: routes.rs answers with a bare // StatusCode::CONFLICT.into_response(), no JSON body at all. globalThis.fetch = async () => new Response(null, { status: 409 });
await assert.rejects( () => deployLobby("lobby-1"), (err) => { assert.ok(err instanceof ApiError); assert.ok(err.message.length > 0); return true; }, );});
// --- connectLobby --------------------------------------------------------
/** A no-op LobbyCallbacks, for a test that only cares about a subset. */function noopCallbacks(overrides = {}) { return { onOpen: () => {}, onOwner: () => {}, onScenario: () => {}, onSeats: () => {}, onLocked: () => {}, onPresence: () => {}, onReady: () => {}, onDeployed: () => {}, onChat: () => {}, onClosed: () => {}, ...overrides, };}
test("connectLobby dispatches each tagged event to its own callback", () => { const owners = []; const scenarios = []; const seatSnapshots = []; const readySnapshots = []; const presenceSnapshots = []; const deployed = []; const chat = []; connectLobby( "lobby-1", noopCallbacks({ onOwner: (did) => owners.push(did), onScenario: (s) => scenarios.push(s), onSeats: (s) => seatSnapshots.push(s), onPresence: (watchers) => presenceSnapshots.push(watchers), onReady: (dids) => readySnapshots.push(dids), onDeployed: (id) => deployed.push(id), onChat: (line) => chat.push(line), }), ); const socket = lastSocket(); assert.ok(socket.url.endsWith("/api/matches/lobby-1/live"));
socket.receive({ type: "owner", owner: "did:plc:abc" }); socket.receive({ type: "scenario", scenario: "TrainingScenarios/1-FirstRun.mms", }); socket.receive({ type: "seats", seats: [ { slot: "TraineeA", control: "human", did: "did:plc:abc", handle: "abc" }, ], }); socket.receive({ type: "presence", here: [{ did: "did:plc:abc", handle: "abc" }], }); socket.receive({ type: "ready", ready: ["did:plc:abc"] }); socket.receive({ type: "chat", did: "did:plc:abc", handle: "abc", text: "garboil", at: 1_700_000_000, }); socket.receive({ type: "deployed", matchId: "match-9" });
assert.deepEqual(owners, ["did:plc:abc"]); assert.deepEqual(scenarios, ["TrainingScenarios/1-FirstRun.mms"]); assert.equal(seatSnapshots.length, 1); assert.equal(seatSnapshots[0][0].slot, "TraineeA"); assert.deepEqual( presenceSnapshots, [[{ did: "did:plc:abc", handle: "abc" }]], "a presence snapshot is the room, handed over whole", ); assert.deepEqual( readySnapshots, [["did:plc:abc"]], "a ready snapshot is a plain list of DIDs, handed over whole", ); assert.equal(chat.length, 1); assert.equal(chat[0].text, "garboil"); assert.equal(chat[0].handle, "abc"); assert.deepEqual(deployed, ["match-9"]);});
test("connectLobby calls onOpen exactly once, when the socket's handshake finishes - not at construction", () => { const opens = []; connectLobby("lobby-1", noopCallbacks({ onOpen: () => opens.push(true) })); const socket = lastSocket();
assert.equal( opens.length, 0, "onOpen fired before the socket ever reached OPEN - it must reflect the " + "real handshake, not just connectLobby() being called", );
socket.open(); assert.equal(opens.length, 1, "onOpen did not fire when the socket opened");
// A raw WebSocket only ever fires "open" once per socket. socket.receive({ type: "deployed", matchId: "match-9" }); assert.equal(opens.length, 1, "onOpen fired more than once for one socket");});
test("onOpen fires before any backlog or live chat line, which then arrive in order", () => { // The contract lobby.ts's own "connected to chat" line depends on: the // socket's open event - and whatever a caller does in response to it - // is dispatched strictly before the backlog the server sends right after // accepting the upgrade, and the backlog itself keeps arriving in send // order, live lines included. const events = []; connectLobby( "lobby-1", noopCallbacks({ onOpen: () => events.push("open"), onChat: (line) => events.push(`chat:${line.text}`), }), ); const socket = lastSocket();
socket.open(); // Backlog: two lines already said before this client connected. socket.receive({ type: "chat", did: "did:plc:a", handle: "a", text: "swive", at: 1, }); socket.receive({ type: "chat", did: "did:plc:a", handle: "a", text: "thole", at: 2, }); // Live: said after the backlog replay finished. socket.receive({ type: "chat", did: "did:plc:b", handle: null, text: "yestreen", at: 3, });
assert.deepEqual(events, [ "open", "chat:swive", "chat:thole", "chat:yestreen", ]);});
test("connectLobby ignores an unrecognized or malformed message rather than throwing", () => { const events = []; connectLobby( "lobby-1", noopCallbacks({ onOwner: () => events.push("owner"), onScenario: () => events.push("scenario"), onSeats: () => events.push("seats"), onLocked: () => events.push("locked"), onPresence: () => events.push("presence"), onReady: () => events.push("ready"), onDeployed: () => events.push("deployed"), onChat: () => events.push("chat"), }), ); const socket = lastSocket();
assert.doesNotThrow(() => { socket.receive({ type: "somethingNew" }); for (const cb of socket.listeners.get("message") ?? []) { cb({ data: "not json" }); } }); assert.deepEqual(events, []);});
test("a lobby replayed as backlog arrives through the same onChat callback as a live line", () => { // There is no separate "history" message - a late joiner's backlog is // just a run of ordinary chat events before the live ones start, so a // caller that only ever appends in arrival order gets the right order // for free. This proves that holds for connectLobby's dispatch, not just // for the wire. const chat = []; connectLobby( "lobby-1", noopCallbacks({ onChat: (line) => chat.push(line.text) }), ); const socket = lastSocket();
socket.receive({ type: "chat", did: "did:plc:a", handle: "a", text: "swive", at: 1, }); socket.receive({ type: "chat", did: "did:plc:a", handle: "a", text: "thole", at: 2, }); socket.receive({ type: "chat", did: "did:plc:b", handle: null, text: "yestreen", at: 3, });
assert.deepEqual(chat, ["swive", "thole", "yestreen"]);});
test("claimSeat, assignBot, inviteHandle, kickSeat, setScenario, sendChat and setReady tag their requests and only send once the socket is open", () => { const lobby = connectLobby("lobby-1", noopCallbacks()); const socket = lastSocket();
// Sent while still CONNECTING: dropped, not thrown - a click landing // before the handshake finishes is a timing accident, not a crash. lobby.claimSeat("TraineeA"); assert.equal(socket.sent.length, 0);
socket.open(); lobby.claimSeat("TraineeA"); lobby.assignBot("TraineeB"); lobby.inviteHandle("TraineeC", "quire.example"); lobby.kickSeat("TraineeA"); lobby.setScenario("TrainingScenarios/2-FinalExam.mms"); lobby.sendChat("garboil"); lobby.setReady(true); lobby.setReady(false); lobby.makeHost("did:plc:rival");
assert.deepEqual(socket.sent, [ { type: "claimSeat", slot: "TraineeA" }, { type: "botSeat", slot: "TraineeB" }, { type: "invite", slot: "TraineeC", handle: "quire.example" }, { type: "kick", slot: "TraineeA" }, { type: "scenario", scenario: "TrainingScenarios/2-FinalExam.mms" }, { type: "chat", text: "garboil" }, { type: "setReady", ready: true }, { type: "setReady", ready: false }, { type: "makeHost", did: "did:plc:rival" }, ]);});
test("close() closes the underlying socket", () => { const lobby = connectLobby("lobby-1", noopCallbacks()); const socket = lastSocket();
lobby.close();
assert.equal(socket.readyState, MockWebSocket.CLOSED);});
test("the socket closing - by either side - calls onClosed", () => { const closed = []; const lobby = connectLobby( "lobby-1", noopCallbacks({ onClosed: () => closed.push(true) }), ); const socket = lastSocket();
lobby.close(); assert.equal( closed.length, 1, "close() from this client did not fire onClosed", );
// A second, independent connection - the server ending it (or refusing // the upgrade in the first place) looks the same from here as this // client's own close() above: one "close" event, no reason attached. const closedTwo = []; connectLobby( "lobby-2", noopCallbacks({ onClosed: () => closedTwo.push(true) }), ); lastSocket().serverClose(); assert.equal( closedTwo.length, 1, "the server ending the connection did not fire onClosed", );});