add tests
authorLaurent Mazet <mazet@softndesign.org>
Mon, 20 Jul 2026 15:53:14 +0000 (17:53 +0200)
committerLaurent Mazet <mazet@softndesign.org>
Tue, 11 Aug 2026 20:26:11 +0000 (22:26 +0200)
test/Makefile [new file with mode: 0644]
test/README.md [new file with mode: 0644]
test/stress-harness.js [new file with mode: 0644]
test/test-harness.js [new file with mode: 0644]

diff --git a/test/Makefile b/test/Makefile
new file mode 100644 (file)
index 0000000..5ae71d6
--- /dev/null
@@ -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 (file)
index 0000000..42db8a8
--- /dev/null
@@ -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 (file)
index 0000000..46c2cf6
--- /dev/null
@@ -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 (file)
index 0000000..d82dd75
--- /dev/null
@@ -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
+        }
+    }
+}());