Files
Battle-for-Tismo/tests/server_test.js
T

275 lines
9.6 KiB
JavaScript

// Integration tests for the Node server: static hosting plus the WebSocket
// protocol (lobby, login, configuration and orders) driven by Node's built-in
// WebSocket client. This also exercises the hand-rolled frame codec with the
// real, large state snapshots.
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { TestCase } from "./framework/test_case.js";
import { adjacentLand, withServer, SEED } from "./framework/helpers.js";
function makeClient(url) {
const ws = new WebSocket(url);
const queue = [];
const waiters = [];
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
for (let i = waiters.length - 1; i >= 0; i--) {
if (waiters[i].type === message.t) {
waiters[i].resolve(message);
waiters.splice(i, 1);
return;
}
}
queue.push(message);
});
return {
ws,
open: new Promise((resolve) => ws.addEventListener("open", resolve)),
send(message) {
ws.send(JSON.stringify(message));
},
next(type, timeout = 4000) {
const index = queue.findIndex((m) => m.t === type);
if (index >= 0) return Promise.resolve(queue.splice(index, 1)[0]);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`timed out waiting for ${type}`)), timeout);
waiters.push({
type,
resolve: (message) => {
clearTimeout(timer);
resolve(message);
},
});
});
},
close() {
ws.close();
},
};
}
export class ServerStaticTest extends TestCase {
async test_serves_the_client_and_redirects_root() {
await withServer(async (port) => {
const response = await fetch(`http://127.0.0.1:${port}/`);
this.assertTrue(response.ok);
const body = await response.text();
this.assertTrue(body.includes("Battle for 'Tismo"), "serves the client page");
});
}
async test_serves_shared_modules_for_the_browser() {
await withServer(async (port) => {
const response = await fetch(`http://127.0.0.1:${port}/shared/hex.js`);
this.assertTrue(response.ok);
this.assertTrue((response.headers.get("content-type") || "").includes("javascript"));
});
}
async test_missing_files_are_404() {
await withServer(async (port) => {
const response = await fetch(`http://127.0.0.1:${port}/nope.txt`);
this.assertEqual(response.status, 404);
});
}
async test_cli_starts_and_reports_its_url() {
const script = fileURLToPath(new URL("../server/server.js", import.meta.url));
const child = spawn(process.execPath, [script, "--port", "0"], { stdio: ["ignore", "pipe", "pipe"] });
try {
const line = await new Promise((resolve, reject) => {
let out = "";
const timer = setTimeout(() => reject(new Error("the CLI did not start")), 5000);
child.stdout.on("data", (chunk) => {
out += chunk.toString();
if (out.includes("listening on")) {
clearTimeout(timer);
resolve(out);
}
});
child.on("error", reject);
});
this.assertTrue(line.includes("listening on"));
this.assertTrue(line.includes("/ws"));
} finally {
child.kill("SIGINT");
}
}
}
export class ServerLoopTest extends TestCase {
async test_step_advances_the_clock_after_a_real_second() {
await withServer(async (port, server) => {
server.gameServer.configureGame({
seed: SEED,
player_civ: 0,
civilisations: ["france", "britain"],
});
this.assertEqual(server.gameServer.state.totalHours, 0);
for (let i = 0; i < 40; i++) server.step(0.05);
this.assertGreater(server.gameServer.state.totalHours, 0, "an hour struck after a second");
});
}
async test_snapshots_are_throttled_between_ticks() {
await withServer(async (port, server) => {
server.gameServer.configureGame({
seed: SEED,
player_civ: 0,
civilisations: ["france", "britain"],
});
// Count how often the loop actually flushes over one second of 50ms ticks.
const flush = server.gameServer.flushIfDirty.bind(server.gameServer);
let flushes = 0;
server.gameServer.flushIfDirty = () => {
flushes += 1;
flush();
};
for (let i = 0; i < 20; i++) server.step(0.05);
this.assertLess(flushes, 20, "not one snapshot per tick");
this.assertGreaterOrEqual(flushes, 9, "about one snapshot per 100ms");
});
}
}
export class ServerProtocolTest extends TestCase {
async test_lobby_login_and_configure() {
await withServer(async (port, server) => {
const client = makeClient(`ws://127.0.0.1:${port}/ws`);
await client.open;
client.send({ t: "request_games" });
const empty = await client.next("game_list");
this.assertEmpty(empty.games, "no game before configuration");
client.send({ t: "login", name: "Host", password: "pw" });
const assigned = await client.next("assigned");
this.assertTrue(assigned.admin);
this.assertEqual(assigned.name, "Host");
await client.next("players");
client.send({
t: "configure_game",
setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] },
});
await client.next("game_configured");
const stateMessage = await client.next("game_state");
this.assertEqual(stateMessage.state.seed, SEED);
this.assertEqual(stateMessage.state.viewer, 0);
this.assertSize(stateMessage.state.civs, 2);
client.send({ t: "request_games" });
const listed = await client.next("game_list");
this.assertSize(listed.games, 1);
this.assertEqual(listed.games[0].seed, SEED);
this.assertEqual(listed.games[0].players, 1);
client.close();
});
}
async test_move_order_is_applied_and_broadcast() {
await withServer(async (port, server) => {
const client = makeClient(`ws://127.0.0.1:${port}/ws`);
await client.open;
client.send({ t: "login", name: "Host", password: "pw" });
await client.next("assigned");
client.send({
t: "configure_game",
setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] },
});
await client.next("game_state");
const state = server.gameServer.state;
const unit = state.units[0];
const goal = adjacentLand(state, unit);
client.send({ t: "order", order: { type: "move", unit: unit.id, coords: [goal.x, goal.y] } });
const snapshot = await client.next("game_state");
const data = snapshot.state.units.find((u) => u.id === unit.id);
this.assertNotNull(data);
this.assertEqual(data.path.length >= 2 || data.pathIndex > 0, true, "the unit has a route");
this.assertEqual(data.coords.length, 2);
client.close();
});
}
async test_wrong_password_is_rejected() {
await withServer(async (port) => {
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
await host.open;
host.send({ t: "login", name: "Host", password: "pw" });
await host.next("assigned");
host.close();
await new Promise((r) => setTimeout(r, 100));
const impostor = makeClient(`ws://127.0.0.1:${port}/ws`);
await impostor.open;
impostor.send({ t: "login", name: "Host", password: "wrong" });
const rejected = await impostor.next("login_rejected");
this.assertTrue(rejected.reason.length > 0);
impostor.close();
});
}
async test_second_player_gets_another_civilisation() {
await withServer(async (port) => {
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
await host.open;
host.send({ t: "login", name: "Host", password: "pw" });
await host.next("assigned");
host.send({
t: "configure_game",
setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] },
});
await host.next("game_state");
const guest = makeClient(`ws://127.0.0.1:${port}/ws`);
await guest.open;
guest.send({ t: "login", name: "Carol", password: "pw" });
const assigned = await guest.next("assigned");
this.assertFalse(assigned.admin);
const choose = await guest.next("choose_civ");
this.assertEqual(choose.civs.map((c) => c.index), [1], "the free civilisation is offered");
guest.send({ t: "claim_civ", civ: 1 });
const civAssigned = await guest.next("civ_assigned");
this.assertEqual(civAssigned.civ, 1);
const state = await guest.next("game_state");
this.assertEqual(state.state.viewer, 1, "the guest is assigned the free civilisation");
this.assertTrue(state.state.units.some((u) => u.civ === 1), "the guest has a unit");
host.close();
guest.close();
});
}
async test_non_admin_cannot_configure() {
await withServer(async (port, server) => {
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
await host.open;
host.send({ t: "login", name: "Host", password: "pw" });
await host.next("assigned");
host.send({
t: "configure_game",
setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] },
});
await host.next("game_state");
const guest = makeClient(`ws://127.0.0.1:${port}/ws`);
await guest.open;
guest.send({ t: "login", name: "Carol", password: "pw" });
await guest.next("assigned");
guest.send({
t: "configure_game",
setup: { seed: 999, player_civ: 1, civilisations: [] },
});
await new Promise((r) => setTimeout(r, 150));
this.assertEqual(server.gameServer.state.seed, SEED, "the game was not reset");
host.close();
guest.close();
});
}
}