From: Laurent Mazet Date: Mon, 20 Jul 2026 15:53:14 +0000 (+0200) Subject: add tests X-Git-Url: https://secure.softndesign.org/git/?a=commitdiff_plain;h=d3da49730f8fc186140a0e5c3e36cba3e8685f20;p=trivabble.git add tests --- diff --git a/test/Makefile b/test/Makefile new file mode 100644 index 0000000..5ae71d6 --- /dev/null +++ b/test/Makefile @@ -0,0 +1,49 @@ +# -*- Makefile -*- + +NODE ?= node +HOST ?= 127.0.0.1 +TEST_PORT ?= 3100 +STRESS_PORT ?= 3200 +STRESS_CONCURRENCY ?= 25 +STRESS_DURATION_MS ?= 15000 +START_SERVER ?= true + +.PHONY: help test stress test-external stress-external + +help: + @echo make -C test test : run validation suite with auto-started server + @echo make -C test stress : run stress suite with auto-started server + @echo make -C test test-external : run validation suite against an already running server + @echo make -C test stress-external : run stress suite against an already running server + +test: + TRIVABBLE_TEST_HOST=$(HOST) \ + TRIVABBLE_TEST_PORT=$(TEST_PORT) \ + TRIVABBLE_START_SERVER=$(START_SERVER) \ + $(NODE) test-harness.js + +stress: + TRIVABBLE_TEST_HOST=$(HOST) \ + TRIVABBLE_TEST_PORT=$(STRESS_PORT) \ + TRIVABBLE_STRESS_CONCURRENCY=$(STRESS_CONCURRENCY) \ + TRIVABBLE_STRESS_DURATION_MS=$(STRESS_DURATION_MS) \ + TRIVABBLE_START_SERVER=$(START_SERVER) \ + $(NODE) stress-harness.js + +test-external: + TRIVABBLE_TEST_HOST=$(HOST) \ + TRIVABBLE_TEST_PORT=$(TEST_PORT) \ + TRIVABBLE_START_SERVER=false \ + $(NODE) test-harness.js + +stress-external: + TRIVABBLE_TEST_HOST=$(HOST) \ + TRIVABBLE_TEST_PORT=$(STRESS_PORT) \ + TRIVABBLE_STRESS_CONCURRENCY=$(STRESS_CONCURRENCY) \ + TRIVABBLE_STRESS_DURATION_MS=$(STRESS_DURATION_MS) \ + TRIVABBLE_START_SERVER=false \ + $(NODE) stress-harness.js + +clean: + touch clean + rm -f clean $(wildcard games.backup.*.json) diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..42db8a8 --- /dev/null +++ b/test/README.md @@ -0,0 +1,33 @@ +# Trivabble server tests + +## Files + +- `test-harness.js`: functional and protocol validation suite +- `stress-harness.js`: concurrent HTTP stress suite +- `Makefile`: convenience targets + +## Run + +From the repository root: + +```sh +make -C test test +make -C test stress +``` + +Against an already running server: + +```sh +make -C test test-external TEST_PORT=3000 +make -C test stress-external STRESS_PORT=3000 +``` + +## Variables + +- `NODE` default: `node` +- `HOST` default: `127.0.0.1` +- `TEST_PORT` default: `3100` +- `STRESS_PORT` default: `3200` +- `STRESS_CONCURRENCY` default: `25` +- `STRESS_DURATION_MS` default: `15000` +- `START_SERVER` default: `true` diff --git a/test/stress-harness.js b/test/stress-harness.js new file mode 100644 index 0000000..46c2cf6 --- /dev/null +++ b/test/stress-harness.js @@ -0,0 +1,252 @@ +"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); + } + } +}()); diff --git a/test/test-harness.js b/test/test-harness.js new file mode 100644 index 0000000..d82dd75 --- /dev/null +++ b/test/test-harness.js @@ -0,0 +1,944 @@ +"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 + } + } +}());