--- /dev/null
+"use strict";
+
+const http = require("http");
+const childProcess = require("child_process");
+const path = require("path");
+
+const HOST = process.env.TRIVABBLE_TEST_HOST || "127.0.0.1";
+const PORT = parseInt(process.env.TRIVABBLE_TEST_PORT || "3200", 10);
+const CONCURRENCY = parseInt(process.env.TRIVABBLE_STRESS_CONCURRENCY || "25", 10);
+const DURATION_MS = parseInt(process.env.TRIVABBLE_STRESS_DURATION_MS || "15000", 10);
+const START_SERVER = (process.env.TRIVABBLE_START_SERVER || "true") !== "false";
+const SERVER_PATH = path.join(__dirname, "..", "server", "trivabble-server.js");
+
+// This script focuses on simple mixed HTTP traffic under concurrency. It is
+// intentionally lightweight and does not try to stress SSE/WebSocket fanout.
+
+const ANSI = {
+ green: "\u001b[32m",
+ red: "\u001b[31m",
+ reset: "\u001b[0m"
+};
+
+function green(text) {
+ return `${ANSI.green}${text}${ANSI.reset}`;
+}
+
+function red(text) {
+ return `${ANSI.red}${text}${ANSI.reset}`;
+}
+
+// -----------------------------------------------------------------------------
+// Helpers
+// -----------------------------------------------------------------------------
+
+function wait(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function randomPlayer(prefix = "stress") {
+ return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
+}
+
+// Sends one JSON command batch and records basic latency/error information.
+function postJson(payload) {
+ return new Promise((resolve) => {
+ const body = JSON.stringify(payload);
+ const start = Date.now();
+ const req = http.request({
+ host: HOST,
+ port: PORT,
+ path: "/",
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(body)
+ }
+ }, (res) => {
+ res.resume();
+ res.on("end", () => {
+ resolve({ok: true, statusCode: res.statusCode, latency: Date.now() - start});
+ });
+ });
+ req.on("error", (error) => {
+ resolve({ok: false, error, latency: Date.now() - start});
+ });
+ req.write(body);
+ req.end();
+ });
+}
+
+// Starts the target server in a child process when self-managed mode is used.
+function startServer() {
+ const env = {
+ ...process.env,
+ TRIVABBLE_HOST: HOST,
+ TRIVABBLE_PORT: String(PORT),
+ TRIVABBLE_GAMES_BACKUP: path.join(__dirname, `games.backup.stress.${process.pid}.json`)
+ };
+
+ const child = childProcess.spawn(process.execPath, [SERVER_PATH], {
+ cwd: path.dirname(SERVER_PATH),
+ env,
+ stdio: ["ignore", "pipe", "pipe"]
+ });
+
+ child.stdout.on("data", (chunk) => process.stdout.write(`[server] ${chunk}`));
+ child.stderr.on("data", (chunk) => process.stderr.write(`[server-err] ${chunk}`));
+ return child;
+}
+
+// Polls the server until a basic hello request succeeds.
+async function waitForServer() {
+ const deadline = Date.now() + 10000;
+ while (Date.now() < deadline) {
+ const result = await postJson({playerName: randomPlayer("probe"), cmds: [{cmd: "hello"}]});
+ if (result.ok && result.statusCode === 200) {
+ return;
+ }
+ await wait(200);
+ }
+ throw new Error("Server did not become ready in time");
+}
+
+// -----------------------------------------------------------------------------
+// Stress workload
+// -----------------------------------------------------------------------------
+
+// Each worker keeps reusing one logical player and eventually one game so the
+// stress mix exercises both stateless and stateful command paths.
+async function worker(stats, endTime) {
+ const playerName = randomPlayer();
+ let gameNumber = null;
+
+ while (Date.now() < endTime) {
+ const action = gameNumber
+ ? Math.floor(Math.random() * 5)
+ : 0;
+
+ let payload;
+ switch (action) {
+ case 0:
+ payload = {playerName, gameNumber, cmds: [{cmd: "joinGame"}]};
+ break;
+ case 1:
+ payload = {playerName, gameNumber, cmds: [{cmd: "hello"}]};
+ break;
+ case 2:
+ payload = {playerName, gameNumber, cmds: [{cmd: "msg", msg: "stress-message-" + Math.random().toString(36).slice(2)}]};
+ break;
+ case 3:
+ payload = {playerName, gameNumber, cmds: [{cmd: "moveLetter", from: "bag", to: "rack", indexTo: Math.floor(Math.random() * 7)}]};
+ break;
+ case 4:
+ default:
+ payload = {playerName, gameNumber, cmds: [{cmd: "currentPlayer", player: playerName}]};
+ break;
+ }
+
+ const result = await postJson(payload);
+ stats.total++;
+ stats.latencies.push(result.latency);
+
+ if (!result.ok) {
+ stats.failed++;
+ continue;
+ }
+
+ if (result.statusCode !== 200) {
+ stats.failed++;
+ continue;
+ }
+
+ stats.succeeded++;
+
+ if (!gameNumber && action === 0) {
+ try {
+ const chunks = [];
+ const joinResult = await new Promise((resolve) => {
+ const body = JSON.stringify(payload);
+ const req = http.request({
+ host: HOST,
+ port: PORT,
+ path: "/",
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(body)
+ }
+ }, (res) => {
+ res.on("data", (chunk) => chunks.push(chunk));
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
+ });
+ req.on("error", () => resolve(""));
+ req.write(body);
+ req.end();
+ });
+ const parsed = JSON.parse(joinResult);
+ if (parsed[0] && parsed[0].gameNumber) {
+ gameNumber = parsed[0].gameNumber;
+ }
+ } catch (e) {
+ stats.failed++;
+ }
+ }
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Reporting
+// -----------------------------------------------------------------------------
+
+// Computes coarse latency percentiles from the collected request durations.
+function percentile(sorted, p) {
+ if (!sorted.length) {
+ return 0;
+ }
+ const index = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
+ return sorted[index];
+}
+
+// Starts the target server if requested, runs the stress window, then prints a
+// compact JSON summary so results are easy to archive or diff.
+(async function main() {
+ let child = null;
+ try {
+ if (START_SERVER) {
+ child = startServer();
+ await waitForServer();
+ }
+
+ const stats = {
+ total: 0,
+ succeeded: 0,
+ failed: 0,
+ latencies: []
+ };
+
+ const endTime = Date.now() + DURATION_MS;
+ await Promise.all(new Array(CONCURRENCY).fill(0).map(() => worker(stats, endTime)));
+
+ const latencies = stats.latencies.slice().sort((a, b) => a - b);
+ console.log(JSON.stringify({
+ host: HOST,
+ port: PORT,
+ concurrency: CONCURRENCY,
+ durationMs: DURATION_MS,
+ totalRequests: stats.total,
+ succeeded: stats.succeeded,
+ failed: stats.failed,
+ p50Ms: percentile(latencies, 0.50),
+ p95Ms: percentile(latencies, 0.95),
+ p99Ms: percentile(latencies, 0.99),
+ maxMs: latencies.length ? latencies[latencies.length - 1] : 0
+ }, null, 2));
+
+ process.exitCode = stats.failed > 0 ? 1 : 0;
+ if (process.exitCode === 0) {
+ console.log(green("Stress test OK"));
+ } else {
+ console.log(red("Stress test KO"));
+ }
+ } catch (error) {
+ console.error(error && error.stack ? error.stack : error);
+ console.log(red("Stress test KO"));
+ process.exitCode = 1;
+ } finally {
+ if (child && !child.killed) {
+ child.kill("SIGTERM");
+ await wait(500);
+ }
+ }
+}());
--- /dev/null
+"use strict";
+
+const http = require("http");
+const net = require("net");
+const crypto = require("crypto");
+const childProcess = require("child_process");
+const path = require("path");
+const fs = require("fs");
+
+const HOST = process.env.TRIVABBLE_TEST_HOST || "127.0.0.1";
+const PORT = parseInt(process.env.TRIVABBLE_TEST_PORT || "3100", 10);
+const SERVER_PATH = path.join(__dirname, "..", "server", "trivabble-server.js");
+const START_SERVER = (process.env.TRIVABBLE_START_SERVER || "true") !== "false";
+const START_TIMEOUT_MS = parseInt(process.env.TRIVABBLE_START_TIMEOUT_MS || "10000", 10);
+const BACKUP_PATH = process.env.TRIVABBLE_TEST_BACKUP || path.join(__dirname, `games.backup.test.${process.pid}.json`);
+
+// When auto-start is enabled, the harness owns the server lifecycle so it can
+// restart the process for persistence and corrupted-backup scenarios.
+let managedServerChild = null;
+
+function assert(condition, message) {
+ if (!condition) {
+ throw new Error(message || "Assertion failed");
+ }
+}
+
+const ANSI = {
+ green: "\u001b[32m",
+ red: "\u001b[31m",
+ reset: "\u001b[0m"
+};
+
+function log(...args) {
+ process.stdout.write(args.join(" ") + "\n");
+}
+
+function green(text) {
+ return `${ANSI.green}${text}${ANSI.reset}`;
+}
+
+function red(text) {
+ return `${ANSI.red}${text}${ANSI.reset}`;
+}
+
+function wait(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function randomPlayer(prefix = "player") {
+ return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
+}
+
+// Minimal HTTP helper used by most black-box API tests.
+function postJson(payload) {
+ return new Promise((resolve, reject) => {
+ const body = JSON.stringify(payload);
+ const req = http.request({
+ host: HOST,
+ port: PORT,
+ path: "/",
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(body)
+ }
+ }, (res) => {
+ let data = "";
+ res.setEncoding("utf8");
+ res.on("data", (chunk) => {
+ data += chunk;
+ });
+ res.on("end", () => resolve({statusCode: res.statusCode, headers: res.headers, body: data}));
+ });
+ req.on("error", reject);
+ req.write(body);
+ req.end();
+ });
+}
+
+function postRaw(body, headers = {}) {
+ return new Promise((resolve, reject) => {
+ const req = http.request({
+ host: HOST,
+ port: PORT,
+ path: "/",
+ method: "POST",
+ headers: {
+ ...headers,
+ "Content-Type": headers["Content-Type"] || "application/json",
+ "Content-Length": Buffer.byteLength(body)
+ }
+ }, (res) => {
+ let data = "";
+ res.setEncoding("utf8");
+ res.on("data", (chunk) => {
+ data += chunk;
+ });
+ res.on("end", () => resolve({statusCode: res.statusCode, headers: res.headers, body: data}));
+ });
+ req.on("error", reject);
+ req.write(body);
+ req.end();
+ });
+}
+
+// Opens a long-lived SSE stream using the server's URL-encoded payload format.
+function sseConnect(initialPayload) {
+ return new Promise((resolve, reject) => {
+ const encoded = encodeURIComponent(JSON.stringify(initialPayload));
+ const req = http.request({
+ host: HOST,
+ port: PORT,
+ path: `/sse/${encoded}`,
+ method: "GET"
+ }, (res) => {
+ res.setEncoding("utf8");
+ resolve({req, res});
+ });
+ req.on("error", reject);
+ req.end();
+ });
+}
+
+// Client-to-server websocket frames must be masked, so the harness provides a
+// tiny encoder instead of depending on an external websocket library.
+function createMaskedWebSocketFrame(data) {
+ const payload = Buffer.from(data, "utf8");
+ const mask = crypto.randomBytes(4);
+ let header;
+
+ if (payload.length < 126) {
+ header = Buffer.alloc(2);
+ header[0] = 0x81;
+ header[1] = 0x80 | payload.length;
+ } else {
+ header = Buffer.alloc(4);
+ header[0] = 0x81;
+ header[1] = 0x80 | 126;
+ header.writeUInt16BE(payload.length, 2);
+ }
+
+ const masked = Buffer.alloc(payload.length);
+ for (let i = 0; i < payload.length; i++) {
+ masked[i] = payload[i] ^ mask[i % 4];
+ }
+
+ return Buffer.concat([header, mask, masked]);
+}
+
+function parseWebSocketFrames(buffer) {
+ const messages = [];
+ let offset = 0;
+
+ while (offset + 2 <= buffer.length) {
+ const first = buffer[offset];
+ const second = buffer[offset + 1];
+ const opcode = first & 0x0f;
+ let payloadLength = second & 0x7f;
+ let headerLength = 2;
+
+ if (payloadLength === 126) {
+ if (offset + 4 > buffer.length) {
+ break;
+ }
+ payloadLength = buffer.readUInt16BE(offset + 2);
+ headerLength = 4;
+ } else if (payloadLength === 127) {
+ throw new Error("Unsupported large websocket frame in test parser");
+ }
+
+ const frameLength = headerLength + payloadLength;
+ if (offset + frameLength > buffer.length) {
+ break;
+ }
+
+ const payload = buffer.slice(offset + headerLength, offset + frameLength);
+ if (opcode === 0x1) {
+ messages.push(payload.toString("utf8"));
+ }
+ offset += frameLength;
+ }
+
+ return {messages, rest: buffer.slice(offset)};
+}
+
+// Minimal websocket handshake helper. Some tests override headers to exercise
+// server-side rejection branches.
+function wsConnect(initialPayload, headers = {}) {
+ return new Promise((resolve, reject) => {
+ const encoded = encodeURIComponent(JSON.stringify(initialPayload));
+ const key = crypto.randomBytes(16).toString("base64");
+ const upgradeHeader = headers.Upgrade || "websocket";
+ const connectionHeader = headers.Connection || "upgrade";
+ const extraHeaders = Object.entries(headers)
+ .filter(([name]) => name !== "Upgrade" && name !== "Connection")
+ .map(([name, value]) => `${name}: ${value}\r\n`)
+ .join("");
+ const socket = net.createConnection({host: HOST, port: PORT}, () => {
+ socket.write(
+ `GET /ws/${encoded} HTTP/1.1\r\n` +
+ `Host: ${HOST}:${PORT}\r\n` +
+ `Upgrade: ${upgradeHeader}\r\n` +
+ `Connection: ${connectionHeader}\r\n` +
+ `Sec-WebSocket-Key: ${key}\r\n` +
+ "Sec-WebSocket-Version: 13\r\n" +
+ extraHeaders +
+ "\r\n"
+ );
+ });
+
+ let handshake = "";
+ let done = false;
+ let binaryBuffer = Buffer.alloc(0);
+
+ socket.on("data", (chunk) => {
+ if (done) {
+ binaryBuffer = Buffer.concat([binaryBuffer, chunk]);
+ const parsed = parseWebSocketFrames(binaryBuffer);
+ binaryBuffer = parsed.rest;
+ for (const msg of parsed.messages) {
+ socket.emit("ws-message", msg);
+ }
+ return;
+ }
+
+ handshake += chunk.toString("binary");
+ const split = handshake.indexOf("\r\n\r\n");
+ if (split !== -1) {
+ done = true;
+ const remaining = Buffer.from(handshake.slice(split + 4), "binary");
+ if (remaining.length) {
+ binaryBuffer = Buffer.concat([binaryBuffer, remaining]);
+ const parsed = parseWebSocketFrames(binaryBuffer);
+ binaryBuffer = parsed.rest;
+ for (const msg of parsed.messages) {
+ socket.emit("ws-message", msg);
+ }
+ }
+ resolve(socket);
+ }
+ });
+
+ socket.on("error", reject);
+ });
+}
+
+// Polls the server until a simple command succeeds. This keeps startup logic
+// independent from server log parsing.
+async function waitForServer() {
+ const start = Date.now();
+ while (Date.now() - start < START_TIMEOUT_MS) {
+ try {
+ const res = await postJson({playerName: randomPlayer("probe"), cmds: [{cmd: "hello"}]});
+ if (res.statusCode === 200) {
+ return;
+ }
+ } catch (e) {
+ await wait(150);
+ }
+ }
+ throw new Error("Server did not start in time");
+}
+
+function startServer() {
+ const env = {
+ ...process.env,
+ TRIVABBLE_HOST: HOST,
+ TRIVABBLE_PORT: String(PORT),
+ TRIVABBLE_GAMES_BACKUP: BACKUP_PATH
+ };
+
+ const child = childProcess.spawn(process.execPath, [SERVER_PATH], {
+ cwd: path.dirname(SERVER_PATH),
+ env,
+ stdio: ["ignore", "pipe", "pipe"]
+ });
+
+ child.stdout.on("data", (chunk) => process.stdout.write(`[server] ${chunk}`));
+ child.stderr.on("data", (chunk) => process.stderr.write(`[server-err] ${chunk}`));
+ managedServerChild = child;
+ return child;
+}
+
+function stopManagedServer() {
+ return new Promise((resolve) => {
+ if (!managedServerChild || managedServerChild.killed) {
+ managedServerChild = null;
+ resolve();
+ return;
+ }
+
+ const child = managedServerChild;
+ managedServerChild = null;
+
+ child.once("exit", () => resolve());
+ child.kill("SIGTERM");
+
+ setTimeout(() => {
+ if (!child.killed) {
+ try {
+ child.kill("SIGKILL");
+ } catch (e) {
+ // ignore cleanup failures
+ }
+ }
+ resolve();
+ }, 3000);
+ });
+}
+
+// -----------------------------------------------------------------------------
+// HTTP tests
+// -----------------------------------------------------------------------------
+
+// Basic health check for the main JSON command endpoint.
+async function testHello() {
+ const playerName = randomPlayer();
+ const res = await postJson({playerName, cmds: [{id: "hello-1", cmd: "hello"}]});
+ assert(res.statusCode === 200, "hello should return HTTP 200");
+ const body = JSON.parse(res.body);
+ assert(Array.isArray(body), "hello response should be an array");
+ assert(body[0].error === 0, "hello should succeed");
+ assert(body[0].version, "hello should include version");
+}
+
+// Invalid JSON should be rejected with a 400 response.
+async function testInvalidJson() {
+ const res = await postRaw("{not-json");
+ assert(res.statusCode === 400, "invalid JSON should return 400");
+ assert(res.body.includes("Unable to parse your JSON data"), "invalid JSON error reason missing");
+}
+
+// A player can join a game and pull a tile from the bag into the rack.
+async function testJoinAndMove() {
+ const playerName = randomPlayer();
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const joinBody = JSON.parse(join.body);
+ assert(joinBody[0].error === 0, "joinGame should succeed");
+ const gameNumber = joinBody[0].gameNumber;
+ assert(Array.isArray(joinBody[0].rack), "joinGame should return a rack array");
+
+ const pop = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "moveLetter", from: "bag", to: "rack", indexTo: 0}]
+ });
+ const popBody = JSON.parse(pop.body);
+ assert(popBody[0].error === 0, "moving from bag to rack should succeed");
+ assert(Array.isArray(popBody[0].rack), "move response should include rack when moving from bag to rack");
+ assert(popBody[0].rack[0], "rack should contain a tile after moving from bag to rack");
+}
+
+// Invalid move parameters should be rejected with specific error reasons.
+async function testMoveValidation() {
+ const playerName = randomPlayer();
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const gameNumber = JSON.parse(join.body)[0].gameNumber;
+
+ const wrongFrom = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "moveLetter", from: "rack", indexFrom: -1, to: "board", indexTo: 0}]
+ });
+ const wrongFromBody = JSON.parse(wrongFrom.body);
+ assert(wrongFromBody[0].error === 1, "invalid move should fail");
+ assert(wrongFromBody[0].reason === "Wrong indexFrom", "invalid move should explain wrong indexFrom");
+
+ const wrongTo = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "moveLetter", from: "bag", to: "rack", indexTo: 99}]
+ });
+ const wrongToBody = JSON.parse(wrongTo.body);
+ assert(wrongToBody[0].error === 1, "move with invalid destination should fail");
+ assert(wrongToBody[0].reason === "Wrong indexTo", "invalid destination should explain wrong indexTo");
+
+ const emptyMove = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "moveLetter", from: "rack", indexFrom: 0, to: "board", indexTo: 0}]
+ });
+ const emptyMoveBody = JSON.parse(emptyMove.body);
+ assert(emptyMoveBody[0].error === 1, "moving from an empty rack slot should fail");
+ assert(emptyMoveBody[0].reason === "Moving from an empty location", "empty move should explain the source is empty");
+}
+
+async function testChatTruncation() {
+ const playerName = randomPlayer();
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const gameNumber = JSON.parse(join.body)[0].gameNumber;
+ const oversized = "x".repeat(5000);
+
+ const send = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "msg", msg: oversized}]
+ });
+ const sendBody = JSON.parse(send.body);
+ assert(sendBody[0].error === 0, "oversized chat message should still be accepted and truncated");
+
+ const state = await postJson({playerName, gameNumber, cmds: [{cmd: "joinGame"}]});
+ const stateBody = JSON.parse(state.body);
+ const lastMsg = stateBody[0].chatHistory[stateBody[0].chatHistory.length - 1];
+ assert(lastMsg.content.length <= stateBody[0].chatMessageMaxSize, "chat message should be truncated to max size");
+}
+
+// Unknown commands should fail cleanly with an explicit reason.
+async function testUnknownCommand() {
+ const playerName = randomPlayer();
+ const res = await postJson({playerName, cmds: [{cmd: "definitelyUnknownCommand"}]});
+ const body = JSON.parse(res.body);
+ assert(body[0].error === 1, "unknown command should fail");
+ assert(body[0].reason === "Unknown command", "unknown command should report the correct reason");
+}
+
+// -----------------------------------------------------------------------------
+// State and command tests
+// -----------------------------------------------------------------------------
+
+// Player rename should reject blank/duplicate names and accept a valid one.
+async function testChangePlayerNameValidation() {
+ const gameNumber = `rename-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
+ const playerA = randomPlayer("rename-a");
+ const playerB = randomPlayer("rename-b");
+
+ await postJson({playerName: playerA, gameNumber, cmds: [{cmd: "hello"}]});
+ await postJson({playerName: playerB, gameNumber, cmds: [{cmd: "hello"}]});
+
+ const blankRename = await postJson({
+ playerName: playerA,
+ gameNumber,
+ cmds: [{cmd: "changePlayerName", playerName: " "}]
+ });
+ const blankRenameBody = JSON.parse(blankRename.body);
+ assert(blankRenameBody[0].error === 1, "blank player rename should fail");
+
+ const duplicateRename = await postJson({
+ playerName: playerA,
+ gameNumber,
+ cmds: [{cmd: "changePlayerName", playerName: playerB}]
+ });
+ const duplicateRenameBody = JSON.parse(duplicateRename.body);
+ assert(duplicateRenameBody[0].error === 1, "duplicate player rename should fail");
+
+ const renamed = await postJson({
+ playerName: playerA,
+ gameNumber,
+ cmds: [{cmd: "changePlayerName", playerName: `${playerA}-renamed`}]
+ });
+ const renamedBody = JSON.parse(renamed.body);
+ assert(renamedBody[0].error === 0, "valid player rename should succeed");
+}
+
+// Rack replacement should reject an invalid rack size.
+async function testSetRackValidation() {
+ const playerName = randomPlayer("rack");
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const joinBody = JSON.parse(join.body);
+ const gameNumber = joinBody[0].gameNumber;
+
+ const tooLargeRack = new Array(20).fill("A");
+ const res = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "setRack", rack: tooLargeRack}]
+ });
+ const body = JSON.parse(res.body);
+ assert(body[0].error === 1, "oversized rack replacement should fail");
+ assert(body[0].reason === "the new rack is not at the right size", "oversized rack should explain size mismatch");
+}
+
+// A player can leave and later rejoin the same game.
+async function testLeaveGameRemovesPlayer() {
+ const gameNumber = `leave-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
+ const playerName = randomPlayer("leave");
+
+ await postJson({playerName, gameNumber, cmds: [{cmd: "hello"}]});
+ const leave = await postJson({playerName, gameNumber, cmds: [{cmd: "leaveGame"}]});
+ const leaveBody = JSON.parse(leave.body);
+ assert(leaveBody[0].error === 0, "leaveGame should succeed");
+
+ const rejoin = await postJson({playerName, gameNumber, cmds: [{cmd: "joinGame"}]});
+ const rejoinBody = JSON.parse(rejoin.body);
+ assert(rejoinBody[0].error === 0, "joinGame after leave should still succeed");
+ assert(Array.isArray(rejoinBody[0].rack), "rejoin should return a rack array");
+}
+
+// -----------------------------------------------------------------------------
+// Realtime tests
+// -----------------------------------------------------------------------------
+
+// SSE clients should receive server-pushed updates for game activity.
+async function testSseReceivesEvents() {
+ const gameNumber = `sse-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
+ const listener = randomPlayer("listener");
+ const actor = randomPlayer("actor");
+
+ const connection = await sseConnect({playerName: listener, gameNumber});
+ let stream = "";
+ connection.res.on("data", (chunk) => {
+ stream += chunk;
+ });
+
+ await wait(200);
+ await postJson({playerName: actor, gameNumber, cmds: [{cmd: "hello"}]});
+ await wait(300);
+
+ connection.req.destroy();
+ assert(stream.includes("data:"), "SSE stream should contain data frames");
+ assert(stream.includes("players"), "SSE stream should include player event data");
+}
+
+// WebSocket clients should be able to send a command and receive a reply.
+async function testWebSocketRoundTrip() {
+ const gameNumber = `ws-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
+ const playerName = randomPlayer("ws");
+ const socket = await wsConnect({playerName, gameNumber});
+
+ const messages = [];
+ socket.on("ws-message", (msg) => {
+ messages.push(msg);
+ });
+
+ await wait(200);
+ const frame = createMaskedWebSocketFrame(JSON.stringify([{cmd: "hello", id: "ws-hello"}]));
+ socket.write(frame);
+ await wait(400);
+ socket.end();
+
+ assert(messages.length > 0, "websocket should receive at least one text frame");
+ assert(messages.some((msg) => msg.includes("ws-hello") || msg.includes("boardLang")), "websocket should receive command reply payload");
+}
+
+// Malformed websocket JSON payloads should trigger a parse error response.
+async function testMalformedWebSocketPayload() {
+ const gameNumber = `wsbad-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
+ const playerName = randomPlayer("wsbad");
+ const socket = await wsConnect({playerName, gameNumber});
+
+ const messages = [];
+ socket.on("ws-message", (msg) => {
+ messages.push(msg);
+ });
+
+ await wait(200);
+ socket.write(createMaskedWebSocketFrame("not-json"));
+ await wait(400);
+ socket.end();
+
+ assert(messages.some((msg) => msg.includes("Unable to parse your JSON data")), "malformed websocket payload should return a parse error");
+}
+
+// Multi-command batches should stop executing once a command fails.
+async function testMultiCommandStopsAfterFailure() {
+ const playerName = randomPlayer("multi");
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const gameNumber = JSON.parse(join.body)[0].gameNumber;
+
+ const res = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [
+ {id: "multi-1", cmd: "hello"},
+ {id: "multi-2", cmd: "definitelyUnknownCommand"},
+ {id: "multi-3", cmd: "currentPlayer", player: playerName}
+ ]
+ });
+ const body = JSON.parse(res.body);
+ assert(body.length === 2, "processing should stop after the first failing command");
+ assert(body[0].error === 0, "first command should succeed");
+ assert(body[1].error === 1, "second command should fail");
+
+ const state = await postJson({playerName, gameNumber, cmds: [{cmd: "joinGame"}]});
+ const stateBody = JSON.parse(state.body);
+ assert(stateBody[0].currentPlayer === "", "commands after a failure should not be executed");
+}
+
+// Chat history should keep only the newest entries when a limit is configured.
+async function testChatHistoryRollsOver() {
+ const playerName = randomPlayer("chatroll");
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const joinBody = JSON.parse(join.body);
+ const gameNumber = joinBody[0].gameNumber;
+ const historyLimit = joinBody[0].chatHistoryLimit;
+
+ if (historyLimit === -1) {
+ return;
+ }
+
+ const totalMessages = historyLimit + 5;
+ for (let i = 0; i < totalMessages; i++) {
+ await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "msg", msg: `roll-${i}`}]
+ });
+ }
+
+ const state = await postJson({playerName, gameNumber, cmds: [{cmd: "joinGame"}]});
+ const stateBody = JSON.parse(state.body);
+ assert(stateBody[0].chatHistory.length === historyLimit, "chat history should be capped at the configured limit");
+ assert(stateBody[0].chatHistory[0].content === `roll-${totalMessages - historyLimit}`, "oldest chat entries should roll off first");
+ assert(stateBody[0].chatHistory[historyLimit - 1].content === `roll-${totalMessages - 1}`, "latest chat entry should be retained");
+}
+
+// Board settings changes should be reflected in subsequent state reads.
+async function testChangeBoardResetsState() {
+ const playerName = randomPlayer("board");
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const joinBody = JSON.parse(join.body);
+ const gameNumber = joinBody[0].gameNumber;
+
+ await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "moveLetter", from: "bag", to: "rack", indexTo: 0}]
+ });
+
+ const res = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "changeBoard", lang: "en", label: "15x15-7", factor: 1, checkFirstWord: 1, freezePlayedTiles: -1}]
+ });
+ const body = JSON.parse(res.body);
+ assert(body[0].error === 0, "changeBoard should succeed");
+ assert(body[0].boardLang === "en", "changeBoard should update board language");
+ assert(body[0].boardLabel === "15x15-7", "changeBoard should report the new board label");
+
+ const state = await postJson({playerName, gameNumber, cmds: [{cmd: "joinGame"}]});
+ const stateBody = JSON.parse(state.body);
+ assert(stateBody[0].boardLang === "en", "game state should keep the updated language");
+ assert(Array.isArray(stateBody[0].rack), "joinGame should still return a rack after board change");
+}
+
+// Score/current-player/flag commands should update observable game state.
+async function testScoreAndFlagsUpdateState() {
+ const playerName = randomPlayer("flags");
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const gameNumber = JSON.parse(join.body)[0].gameNumber;
+
+ const scoreRes = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "score", player: playerName, score: 42}]
+ });
+ const scoreBody = JSON.parse(scoreRes.body);
+ assert(scoreBody[0].error === 0, "score command should succeed");
+
+ const currentPlayerRes = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "currentPlayer", player: playerName}]
+ });
+ const currentPlayerBody = JSON.parse(currentPlayerRes.body);
+ assert(currentPlayerBody[0].error === 0, "currentPlayer command should succeed");
+
+ const checkFirstWordRes = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "checkFirstWord", checkFirstWord: -1}]
+ });
+ const checkFirstWordBody = JSON.parse(checkFirstWordRes.body);
+ assert(checkFirstWordBody[0].error === 0, "checkFirstWord command should succeed");
+
+ const freezePlayedTilesRes = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "freezePlayedTiles", freezePlayedTiles: 1}]
+ });
+ const freezePlayedTilesBody = JSON.parse(freezePlayedTilesRes.body);
+ assert(freezePlayedTilesBody[0].error === 0, "freezePlayedTiles command should succeed");
+
+ const state = await postJson({playerName, gameNumber, cmds: [{cmd: "joinGame"}]});
+ const stateBody = JSON.parse(state.body);
+ assert(stateBody[0].currentPlayer === playerName, "currentPlayer state should be updated");
+ assert(stateBody[0].checkFirstWord === -1, "checkFirstWord state should be updated");
+ assert(stateBody[0].freezePlayedTiles === 1, "freezePlayedTiles state should be updated");
+}
+
+// Oversized request bodies should be rejected by closing the connection.
+async function testOversizedPostClosesConnection() {
+ const body = "x".repeat(1000001);
+ let gotFailure = false;
+
+ try {
+ await postRaw(body, {"Content-Type": "text/plain"});
+ } catch (error) {
+ gotFailure = true;
+ }
+
+ assert(gotFailure, "oversized POST should close the connection");
+}
+
+// Invalid websocket upgrade attempts should be rejected or closed immediately.
+async function testInvalidWebSocketUpgradeRejected() {
+ const playerName = randomPlayer("wsupgrade");
+ const gameNumber = `wsupgrade-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
+ const encoded = encodeURIComponent(JSON.stringify({playerName, gameNumber}));
+
+ const result = await new Promise((resolve) => {
+ const socket = net.createConnection({host: HOST, port: PORT}, () => {
+ socket.write(
+ `GET /ws/${encoded} HTTP/1.1\r\n` +
+ `Host: ${HOST}:${PORT}\r\n` +
+ "Upgrade: not-websocket\r\n" +
+ "Connection: upgrade\r\n" +
+ `Sec-WebSocket-Key: ${crypto.randomBytes(16).toString("base64")}\r\n` +
+ "Sec-WebSocket-Version: 13\r\n" +
+ "\r\n"
+ );
+ });
+
+ const chunks = [];
+ let settled = false;
+
+ function finish(value) {
+ if (!settled) {
+ settled = true;
+ resolve(value);
+ }
+ }
+
+ socket.on("data", (chunk) => chunks.push(chunk));
+ socket.on("error", () => finish(Buffer.concat(chunks).toString("utf8")));
+ socket.on("close", () => finish(Buffer.concat(chunks).toString("utf8")));
+
+ setTimeout(() => {
+ try {
+ socket.end();
+ } catch (e) {
+ // ignore socket shutdown errors
+ }
+ finish(Buffer.concat(chunks).toString("utf8"));
+ }, 300);
+ });
+
+ assert(result.includes("400 Bad Request") || result.length === 0, "invalid websocket upgrade should be rejected");
+}
+
+// -----------------------------------------------------------------------------
+// Persistence tests
+// -----------------------------------------------------------------------------
+
+// Game state should survive a stop/start cycle via the backup file.
+async function testStatePersistsToBackupOnShutdown() {
+ assert(START_SERVER, "persistence test requires the harness to manage the server process");
+
+ try {
+ fs.unlinkSync(BACKUP_PATH);
+ } catch (e) {
+ // ignore if file does not exist
+ }
+
+ const playerName = randomPlayer("persist");
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const joinBody = JSON.parse(join.body);
+ const gameNumber = joinBody[0].gameNumber;
+
+ await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "msg", msg: "persist-check"}]
+ });
+
+ await wait(6000);
+ await stopManagedServer();
+
+ assert(fs.existsSync(BACKUP_PATH), "backup file should exist after shutdown");
+
+ startServer();
+ await waitForServer();
+
+ const restored = await postJson({playerName, gameNumber, cmds: [{cmd: "joinGame"}]});
+ const restoredBody = JSON.parse(restored.body);
+ assert(restoredBody[0].error === 0, "restored game should be readable after restart");
+ assert(restoredBody[0].chatHistory.some((msg) => msg.content === "persist-check"), "chat history should be restored from backup after restart");
+}
+
+// A corrupted backup file should log a warning but not block startup.
+async function testBrokenBackupFileDoesNotPreventStartup() {
+ assert(START_SERVER, "broken backup test requires managed server mode");
+
+ fs.writeFileSync(BACKUP_PATH, "{broken-json");
+ await stopManagedServer();
+ startServer();
+ await waitForServer();
+
+ const res = await postJson({playerName: randomPlayer("broken-backup"), cmds: [{cmd: "hello"}]});
+ const body = JSON.parse(res.body);
+ assert(body[0].error === 0, "server should still start with a broken backup file");
+}
+
+// Resetting a game should clear tiles previously placed on the board.
+async function testResetGameClearsBoardState() {
+ const playerName = randomPlayer("reset");
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const joinBody = JSON.parse(join.body);
+ const gameNumber = joinBody[0].gameNumber;
+
+ await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "moveLetter", from: "bag", to: "rack", indexTo: 0}]
+ });
+
+ await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "moveLetter", from: "rack", indexFrom: 0, to: "board", indexTo: 0}]
+ });
+
+ const reset = await postJson({playerName, gameNumber, cmds: [{cmd: "resetGame"}]});
+ const resetBody = JSON.parse(reset.body);
+ assert(resetBody[0].error === 0, "resetGame should succeed");
+
+ const state = await postJson({playerName, gameNumber, cmds: [{cmd: "joinGame"}]});
+ const stateBody = JSON.parse(state.body);
+ assert(stateBody[0].board.every((cell) => cell === ""), "resetGame should clear the board");
+}
+
+
+// -----------------------------------------------------------------------------
+// Stress-style validation tests
+// -----------------------------------------------------------------------------
+
+// A burst of parallel hello requests should all complete successfully.
+async function stressHttpBurst() {
+ const requests = [];
+ for (let i = 0; i < 100; i++) {
+ requests.push(postJson({playerName: randomPlayer("burst"), cmds: [{cmd: "hello", id: `hello-${i}`}]}));
+ }
+
+ const results = await Promise.all(requests);
+ assert(results.every((res) => res.statusCode === 200), "all burst requests should succeed");
+}
+
+// Repeated sequential game operations should leave the game readable.
+async function stressSequentialGameOps() {
+ const playerName = randomPlayer("ops");
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ const gameNumber = JSON.parse(join.body)[0].gameNumber;
+
+ for (let i = 0; i < 50; i++) {
+ const target = i % 7;
+ const fill = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "moveLetter", from: "bag", to: "rack", indexTo: target, id: `fill-${i}`}]
+ });
+ const fillBody = JSON.parse(fill.body);
+ if (fillBody[0].error !== 0) {
+ break;
+ }
+ }
+
+ const state = await postJson({playerName, gameNumber, cmds: [{cmd: "joinGame"}]});
+ assert(state.statusCode === 200, "state read after sequential ops should succeed");
+}
+
+// Runs all scenarios sequentially so that failures are easier to correlate
+// with recent server logs and side effects stay predictable.
+async function runTests() {
+ const httpTests = [
+ testHello,
+ testInvalidJson,
+ testUnknownCommand,
+ testOversizedPostClosesConnection
+ ];
+
+ const stateAndCommandTests = [
+ testJoinAndMove,
+ testMoveValidation,
+ testChangePlayerNameValidation,
+ testSetRackValidation,
+ testLeaveGameRemovesPlayer,
+ testChatTruncation,
+ testChatHistoryRollsOver,
+ testMultiCommandStopsAfterFailure,
+ testChangeBoardResetsState,
+ testScoreAndFlagsUpdateState,
+ testResetGameClearsBoardState
+ ];
+
+ const realtimeTests = [
+ testSseReceivesEvents,
+ testWebSocketRoundTrip,
+ testMalformedWebSocketPayload,
+ testInvalidWebSocketUpgradeRejected
+ ];
+
+ const persistenceTests = [
+ testStatePersistsToBackupOnShutdown,
+ testBrokenBackupFileDoesNotPreventStartup
+ ];
+
+ const stressTests = [
+ stressHttpBurst,
+ stressSequentialGameOps
+ ];
+
+ const groups = [
+ {name: "HTTP", tests: httpTests},
+ {name: "STATE", tests: stateAndCommandTests},
+ {name: "REALTIME", tests: realtimeTests},
+ {name: "PERSISTENCE", tests: persistenceTests},
+ {name: "STRESS", tests: stressTests}
+ ];
+
+ for (const group of groups) {
+ log(`\n=== ${group.name} ===`);
+ for (const test of group.tests) {
+ log(`RUN ${test.name}`);
+ try {
+ await test();
+ log(`${green("OK")} ${test.name}`);
+ } catch (error) {
+ log(`${red("KO")} ${test.name}`);
+ throw error;
+ }
+ }
+ }
+}
+
+(async function main() {
+ try {
+ if (START_SERVER) {
+ startServer();
+ await waitForServer();
+ }
+ await runTests();
+ log(green("All tests passed."));
+ process.exitCode = 0;
+ } catch (error) {
+ console.error(error && error.stack ? error.stack : error);
+ process.exitCode = 1;
+ } finally {
+ await stopManagedServer();
+ try {
+ fs.unlinkSync(BACKUP_PATH);
+ } catch (e) {
+ // ignore cleanup failures
+ }
+ }
+}());