# Trivabble
-Trivabble is free a network Scrabble game under the (AGPLv3 license)[LICENSE].
+Trivabble is a free network Scrabble game under the [AGPLv3 license](LICENSE).
It is not in any way connected to Hasbro, who has the rights on the trademark Scrabble.
Trivabble provides a board, a bag full of tiles, racks and a chat.
People can play without registering and join each other by using the same game number.
- a board, a bag full of tiles, a rack for every player
- a chat so people can communicate
- a way to set the score of each player
- - support for different languages (set of tiles as well as the interface). Thanks to Wikipedia for providing data about tiles for each language (https://fr.wikipedia.org/wiki/Lettres_du_Scrabble)
+ - support for different languages (tile sets as well as the interface). Thanks to Wikipedia for providing data about tiles for each language (https://fr.wikipedia.org/wiki/Lettres_du_Scrabble)
Trivabble does not implement any rule: you are free to play your way.
It does not understand what you do and is not able to compute the score, like your real board game.
This documentation is intented for people who want to install Trivabble on their server.
If you just want to play, head to https://trivabble.org/.
-For contributions, see (contributing)[CONTRIBUTING.md].
+For contributions, see [CONTRIBUTING.md](CONTRIBUTING.md).
## Set up for production
- The server is a Javascript program that runs on Node JS.
It waits for HTTP and WebSocket requests on a configurable port (default: 3000), and a configurable address (default: localhost).
- This server needs to run for Trivabble to work, and proxified through a Web server like Apache or Nginx.
-- The client is a set of static files that need to be server by a Web server
+ This server needs to run for Trivabble to work, and should be proxied through a Web server like Apache or Nginx.
+- The client is a set of static files that need to be served by a Web server
-### Prerequisite
+### Prerequisites
You need to have:
- Node (version 10 or later)
apt install make nodejs git
-On some systems, the node binary does not exist and is called `nodejs` instead. Trivabble expects the Node binary to be `node`. On these systems, a `nodejs-legacy` package may exist to fix this. Otherwise, you will need to create a symblink.
+On some systems, the node binary does not exist and is called `nodejs` instead. Trivabble expects the Node binary to be `node`. On these systems, a `nodejs-legacy` package may exist to fix this. Otherwise, you will need to create a symlink.
-As for the web server, we will asume that you already have one running and correctly configured.
+As for the web server, we will assume that you already have one running and correctly configured.
### Installation
The server should be outside the root of your Web server.
The client will be served by your Web server.
- Let's store the chosen paths in environment variables (adapt if necessary) and create these directories:
+ Let's store the chosen paths in environment variables (adapt if necessary) and create those directories:
```sh
export TRIVABBLE_SERVER_PATH=/opt/trivabble-server
sudo chown www-data:www-data "$TRIVABBLE_PUBLIC_PATH"
sudo chmod g+x "$TRIVABBLE_PUBLIC_PATH"
sudo mkdir -p "$TRIVABBLE_SERVER_PATH"
- sudo chown trivabble:trivabble "$TRIVABBLE_PUBLIC_PATH"
+ sudo chown trivabble:trivabble "$TRIVABBLE_SERVER_PATH"
```
2. Clone the repository somewhere (in your HOME directory for instance):
export TRIVABBLE_PUBLIC_PATH=/var/www/html/trivabble
```
- Also make sure you still are in the trivabble repository.
+ Also make sure you are still in the trivabble repository.
5. Run, as a user having sudo privileges:
node trivabble-server
```
- You may want to run this in a `screen` so you can leave the session and trivabble-server still runs in the background.
+ You may want to run this in a `screen` session so you can leave the session while trivabble-server keeps running in the background.
Better yet, create a service for your init system:
## Enable the spell checker
-The process of building the dictionaries takes a lot of disk space and require huge amount of memory (around 1GB).
+The process of building the dictionaries takes a lot of disk space and requires a huge amount of memory (around 1GB).
Install `wget` and `aspell` (to have the `word-list-compress` command), and run:
echo > public/dict/list.js
```
+## Testing
+
+The repository includes a split functional validation suite and several stress-test entry points under `test/`.
+
+Run the validation suite:
+
+```sh
+make -C test test
+```
+
+Run individual validation sections:
+
+```sh
+make -C test test-http
+make -C test test-state
+make -C test test-realtime
+make -C test test-persistence
+make -C test test-stress-style
+```
+
+Run all stress sections:
+
+```sh
+make -C test stress
+```
+
+Run individual stress sections:
+
+```sh
+make -C test stress-mixed-http
+make -C test stress-http-burst
+make -C test stress-sequential-game-ops
+```
+
+Run against an already running server:
+
+```sh
+make -C test test-external TEST_PORT=3000
+make -C test stress-external STRESS_PORT=3000
+```
+
+Run individual validation sections against an already running server:
+
+```sh
+make -C test test-http-external TEST_PORT=3000
+make -C test test-state-external TEST_PORT=3000
+make -C test test-realtime-external TEST_PORT=3000
+make -C test test-persistence-external TEST_PORT=3000
+make -C test test-stress-style-external TEST_PORT=3000
+```
+
+The validation suite is split into multiple files:
+- `test/test-common.js`: shared helpers
+- `test/test-runner.js`: section orchestrator
+- `test/test-http.js`: HTTP/API validation section
+- `test/test-state.js`: state and command validation section
+- `test/test-realtime.js`: SSE/WebSocket validation section
+- `test/test-persistence.js`: persistence/restart validation section
+- `test/test-stress-style.js`: lightweight stress-style validation section
+
+The stress suite is split into multiple files:
+- `test/stress-common.js`: shared helpers
+- `test/stress-mixed-http.js`: mixed HTTP workload
+- `test/stress-http-burst.js`: parallel hello burst workload
+- `test/stress-sequential-game-ops.js`: repeated game-operation workload
+
+For more details, see `test/README.md`.
+
## Technical appendices
### Board extractor
#### Special languages
-Some languages are only defined into the English version. So we copy them into separated text files and parsing is done by an AWK named `make_board.awk`.
+Some languages are only defined in the English version. So we copy them into separate text files and parsing is done by an AWK script named `make_board.awk`.
### Dictionary building process
Rule '$(OBJ_DIR)/src.mk' retrieves on Aspell site the last version of the dictionary list, extract dictionary archive URL and build a sub-makefile with rules to retrieve every needed dictionary.
-Rule '%.dir' unpacks dictionary archives and build Aspell dictionary. Result directory is rename as LANG.dir
+Rule '%.dir' unpacks dictionary archives and builds an Aspell dictionary. The result directory is renamed as LANG.dir
-Rule '%.low' expands a Aspell dictionary into a list of words (low means "List Of Words"". Phrases are split into words. Finally the list is ordered and clean from duplicated words.
+Rule '%.low' expands an Aspell dictionary into a list of words (LOW means "List Of Words"). Phrases are split into words. Finally the list is ordered and cleaned of duplicate words.
Rule '$(ROOT_DICT)/%.dict':
- excludes words with numbers, dashes, apostrophes and other forbidden signs,
- and remove duplicated words.
This file can be used by Trivabble for spell checking.
-Last rule 'check-%' checks that file %.dict contains only words with characters base on language tile bag.
+The last rule 'check-%' checks that file %.dict contains only words with characters based on the language tile bag.
#### Remarks
Thanks [Laurent Mazet](http://www.softndesign.org/~mazet/) for many contributions.
Thanks [Ysabeau](https://linuxfr.org/users/ysabeau/) for the design of the bag.
-Thanks every other past and future contributors who have written code, tickets,
-tested the game and who I have not mentionned here yet (please tell me if your
+Thanks to all other past and future contributors who have written code, tickets,
+tested the game and who I have not mentioned here yet (please tell me if your
name is missing!).
STRESS_DURATION_MS ?= 15000
START_SERVER ?= true
-.PHONY: help test stress test-external stress-external
+.PHONY: help test test-http test-state test-realtime test-persistence test-stress-style stress stress-mixed-http stress-http-burst stress-sequential-game-ops test-external test-http-external test-state-external test-realtime-external test-persistence-external test-stress-style-external stress-external stress-mixed-http-external stress-http-burst-external stress-sequential-game-ops-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
+ @echo make -C test test : run validation suite with auto-started server
+ @echo make -C test test-http : run HTTP validation section
+ @echo make -C test test-state : run state/command validation section
+ @echo make -C test test-realtime : run realtime validation section
+ @echo make -C test test-persistence : run persistence validation section
+ @echo make -C test test-stress-style : run stress-style validation section
+ @echo make -C test stress : run all stress sections with auto-started server
+ @echo make -C test stress-mixed-http : run mixed HTTP stress section
+ @echo make -C test stress-http-burst : run hello burst stress section
+ @echo make -C test stress-sequential-game-ops : run sequential game-ops stress section
+ @echo make -C test test-external : run validation suite against an already running server
+ @echo make -C test test-http-external : run HTTP validation section externally
+ @echo make -C test test-state-external : run state/command validation section externally
+ @echo make -C test test-realtime-external : run realtime validation section externally
+ @echo make -C test test-persistence-external : run persistence validation section externally
+ @echo make -C test test-stress-style-external : run stress-style validation section externally
+ @echo make -C test stress-external : run all stress sections against an already running server
+ @echo make -C test stress-mixed-http-external : run mixed HTTP stress section externally
+ @echo make -C test stress-http-burst-external : run hello burst stress section externally
+ @echo make -C test stress-sequential-game-ops-external : run sequential game-ops stress section externally
test:
TRIVABBLE_TEST_HOST=$(HOST) \
TRIVABBLE_TEST_PORT=$(TEST_PORT) \
TRIVABBLE_START_SERVER=$(START_SERVER) \
- $(NODE) test-harness.js
+ $(NODE) test-runner.js
-stress:
+test-http:
+ TRIVABBLE_TEST_HOST=$(HOST) \
+ TRIVABBLE_TEST_PORT=$(TEST_PORT) \
+ TRIVABBLE_START_SERVER=$(START_SERVER) \
+ $(NODE) test-http.js
+
+test-state:
+ TRIVABBLE_TEST_HOST=$(HOST) \
+ TRIVABBLE_TEST_PORT=$(TEST_PORT) \
+ TRIVABBLE_START_SERVER=$(START_SERVER) \
+ $(NODE) test-state.js
+
+test-realtime:
+ TRIVABBLE_TEST_HOST=$(HOST) \
+ TRIVABBLE_TEST_PORT=$(TEST_PORT) \
+ TRIVABBLE_START_SERVER=$(START_SERVER) \
+ $(NODE) test-realtime.js
+
+test-persistence:
+ TRIVABBLE_TEST_HOST=$(HOST) \
+ TRIVABBLE_TEST_PORT=$(TEST_PORT) \
+ TRIVABBLE_START_SERVER=$(START_SERVER) \
+ $(NODE) test-persistence.js
+
+test-stress-style:
+ TRIVABBLE_TEST_HOST=$(HOST) \
+ TRIVABBLE_TEST_PORT=$(TEST_PORT) \
+ TRIVABBLE_START_SERVER=$(START_SERVER) \
+ $(NODE) test-stress-style.js
+
+stress: stress-mixed-http stress-http-burst stress-sequential-game-ops
+
+stress-mixed-http:
+ 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-mixed-http.js
+
+stress-http-burst:
+ 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-http-burst.js
+
+stress-sequential-game-ops:
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
+ $(NODE) stress-sequential-game-ops.js
test-external:
TRIVABBLE_TEST_HOST=$(HOST) \
TRIVABBLE_TEST_PORT=$(TEST_PORT) \
TRIVABBLE_START_SERVER=false \
- $(NODE) test-harness.js
+ $(NODE) test-runner.js
+
+test-http-external:
+ TRIVABBLE_TEST_HOST=$(HOST) \
+ TRIVABBLE_TEST_PORT=$(TEST_PORT) \
+ TRIVABBLE_START_SERVER=false \
+ $(NODE) test-http.js
+
+test-state-external:
+ TRIVABBLE_TEST_HOST=$(HOST) \
+ TRIVABBLE_TEST_PORT=$(TEST_PORT) \
+ TRIVABBLE_START_SERVER=false \
+ $(NODE) test-state.js
+
+test-realtime-external:
+ TRIVABBLE_TEST_HOST=$(HOST) \
+ TRIVABBLE_TEST_PORT=$(TEST_PORT) \
+ TRIVABBLE_START_SERVER=false \
+ $(NODE) test-realtime.js
+
+test-persistence-external:
+ TRIVABBLE_TEST_HOST=$(HOST) \
+ TRIVABBLE_TEST_PORT=$(TEST_PORT) \
+ TRIVABBLE_START_SERVER=false \
+ $(NODE) test-persistence.js
+
+test-stress-style-external:
+ TRIVABBLE_TEST_HOST=$(HOST) \
+ TRIVABBLE_TEST_PORT=$(TEST_PORT) \
+ TRIVABBLE_START_SERVER=false \
+ $(NODE) test-stress-style.js
+
+stress-external: stress-mixed-http-external stress-http-burst-external stress-sequential-game-ops-external
+
+stress-mixed-http-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-mixed-http.js
+
+stress-http-burst-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-http-burst.js
-stress-external:
+stress-sequential-game-ops-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
+ $(NODE) stress-sequential-game-ops.js
clean:
touch clean
## Files
-- `test-harness.js`: functional and protocol validation suite
-- `stress-harness.js`: concurrent HTTP stress suite
+### Validation suite
+
+- `test-common.js`: shared helpers for validation runners
+- `test-runner.js`: orchestrates all validation sections
+- `test-http.js`: HTTP/API validation section
+- `test-state.js`: state and command validation section
+- `test-realtime.js`: SSE/WebSocket validation section
+- `test-persistence.js`: persistence/restart validation section
+- `test-stress-style.js`: lightweight stress-style validation section
+
+### Stress suite
+
+- `stress-common.js`: shared helpers for stress runners
+- `stress-mixed-http.js`: mixed HTTP stress workload
+- `stress-http-burst.js`: parallel hello-request burst workload
+- `stress-sequential-game-ops.js`: repeated sequential game-operation workload
+
+### Tooling
+
- `Makefile`: convenience targets
## Run
make -C test stress
```
+Run individual validation sections:
+
+```sh
+make -C test test-http
+make -C test test-state
+make -C test test-realtime
+make -C test test-persistence
+make -C test test-stress-style
+```
+
+Run individual stress sections:
+
+```sh
+make -C test stress-mixed-http
+make -C test stress-http-burst
+make -C test stress-sequential-game-ops
+```
+
Against an already running server:
```sh
make -C test stress-external STRESS_PORT=3000
```
+Run individual validation sections against an already running server:
+
+```sh
+make -C test test-http-external TEST_PORT=3000
+make -C test test-state-external TEST_PORT=3000
+make -C test test-realtime-external TEST_PORT=3000
+make -C test test-persistence-external TEST_PORT=3000
+make -C test test-stress-style-external TEST_PORT=3000
+```
+
+Run individual stress sections against an already running server:
+
+```sh
+make -C test stress-mixed-http-external STRESS_PORT=3000
+make -C test stress-http-burst-external STRESS_PORT=3000
+make -C test stress-sequential-game-ops-external STRESS_PORT=3000
+```
+
+## Notes
+
+- `make -C test test` runs all validation sections in sequence through `test-runner.js`.
+- `make -C test test-external` runs all validation sections against an already running server.
+- `make -C test stress` runs all stress sections in sequence.
+- `make -C test stress-external` runs all stress sections against an already running server.
+- Validation sections print colored OK/KO status lines.
+- Stress runners print a JSON summary followed by a colored OK/KO status line.
+
## Variables
- `NODE` default: `node`
--- /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");
+
+/* Shared helpers for the split stress runners. These utilities keep section */
+/* files focused on workload generation instead of process and reporting */
+/* logic. */
+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}`;
+}
+
+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 captures latency plus the raw response */
+/* body. */
+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) => {
+ const chunks = [];
+ res.on("data", (chunk) => chunks.push(chunk));
+ res.on("end", () => {
+ resolve({
+ ok: true,
+ statusCode: res.statusCode,
+ latency: Date.now() - start,
+ body: Buffer.concat(chunks).toString("utf8")
+ });
+ });
+ });
+ req.on("error", (error) => {
+ resolve({ok: false, error, latency: Date.now() - start, body: ""});
+ });
+ 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");
+}
+
+/* Computes a coarse percentile from sorted latency samples. */
+function percentile(sorted, p) {
+ if (!sorted.length) {
+ return 0;
+ }
+ const index = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
+ return sorted[index];
+}
+
+/* Runs one stress section with shared startup, reporting, and cleanup */
+/* behavior. */
+async function runStressSection(sectionName, worker) {
+ 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({
+ section: sectionName,
+ 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;
+ console.log(process.exitCode === 0 ? green(`Stress ${sectionName} OK`) : red(`Stress ${sectionName} KO`));
+ } catch (error) {
+ console.error(error && error.stack ? error.stack : error);
+ console.log(red(`Stress ${sectionName} KO`));
+ process.exitCode = 1;
+ } finally {
+ if (child && !child.killed) {
+ child.kill("SIGTERM");
+ await wait(500);
+ }
+ }
+}
+
+module.exports = {
+ CONCURRENCY,
+ DURATION_MS,
+ HOST,
+ PORT,
+ green,
+ postJson,
+ randomPlayer,
+ runStressSection
+};
+++ /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";
+
+/*
+ * -----------------------------------------------------------------------------
+ * HTTP burst stress section
+ * -----------------------------------------------------------------------------
+ */
+const {postJson, randomPlayer, runStressSection} = require("./stress-common");
+
+/* Each worker repeatedly sends simple hello requests to maximize request */
+/* rate and measure basic HTTP responsiveness under parallel load. */
+async function worker(stats, endTime) {
+ while (Date.now() < endTime) {
+ const result = await postJson({
+ playerName: randomPlayer("burst"),
+ cmds: [{cmd: "hello", id: `hello-${Date.now()}-${Math.floor(Math.random() * 1e6)}`}]
+ });
+
+ stats.total++;
+ stats.latencies.push(result.latency);
+ if (!result.ok || result.statusCode !== 200) {
+ stats.failed++;
+ continue;
+ }
+ stats.succeeded++;
+ }
+}
+
+runStressSection("http-burst", worker);
--- /dev/null
+"use strict";
+
+/* -----------------------------------------------------------------------------
+ * Mixed HTTP stress section
+ * -----------------------------------------------------------------------------
+*/
+
+const {postJson, randomPlayer, runStressSection} = require("./stress-common");
+
+/* Each worker mixes stateless and stateful commands to exercise common */
+/* server paths under concurrent HTTP load. */
+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 || result.statusCode !== 200) {
+ stats.failed++;
+ continue;
+ }
+
+ stats.succeeded++;
+
+ if (!gameNumber && action === 0) {
+ try {
+ const parsed = JSON.parse(result.body);
+ if (parsed[0] && parsed[0].gameNumber) {
+ gameNumber = parsed[0].gameNumber;
+ }
+ } catch (e) {
+ stats.failed++;
+ }
+ }
+ }
+}
+
+runStressSection("mixed-http", worker);
--- /dev/null
+"use strict";
+
+/* -----------------------------------------------------------------------------
+ * Sequential game-operations stress section
+ * -----------------------------------------------------------------------------
+ */
+
+const {postJson, randomPlayer, runStressSection} = require("./stress-common");
+
+/* Each worker creates or joins one game and then keeps applying stateful */
+/* operations to stress repeated game mutations over time. */
+async function worker(stats, endTime) {
+ const playerName = randomPlayer("ops");
+ let gameNumber = null;
+
+ while (Date.now() < endTime) {
+ if (!gameNumber) {
+ const join = await postJson({playerName, cmds: [{cmd: "joinGame"}]});
+ stats.total++;
+ stats.latencies.push(join.latency);
+
+ if (!join.ok || join.statusCode !== 200) {
+ stats.failed++;
+ continue;
+ }
+
+ stats.succeeded++;
+ try {
+ const body = JSON.parse(join.body);
+ gameNumber = body[0] && body[0].gameNumber ? body[0].gameNumber : null;
+ } catch (e) {
+ stats.failed++;
+ continue;
+ }
+ continue;
+ }
+
+ const fill = await postJson({
+ playerName,
+ gameNumber,
+ cmds: [{cmd: "moveLetter", from: "bag", to: "rack", indexTo: Math.floor(Math.random() * 7)}]
+ });
+ stats.total++;
+ stats.latencies.push(fill.latency);
+
+ if (!fill.ok || fill.statusCode !== 200) {
+ stats.failed++;
+ continue;
+ }
+
+ stats.succeeded++;
+ }
+}
+
+runStressSection("sequential-game-ops", worker);
--- /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`);
+
+/* Shared helpers for the split validation runners. These utilities keep */
+/* section files focused on test logic instead of process and protocol */
+/* details. */
+
+/* When auto-start is enabled, the shared harness owns the server lifecycle */
+/* so section runners can restart the process for persistence scenarios. */
+let managedServerChild = null;
+
+const ANSI = {
+ green: "\u001b[32m",
+ red: "\u001b[31m",
+ reset: "\u001b[0m"
+};
+
+function assert(condition, message) {
+ if (!condition) {
+ throw new Error(message || "Assertion failed");
+ }
+}
+
+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)}`;
+}
+
+/* Sends one JSON command batch and captures the raw response. */
+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 one server-sent-events 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 websocket frames must be masked, so tests build frames manually. */
+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)};
+}
+
+/* Opens one websocket connection without external dependencies. */
+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 basic hello request succeeds. */
+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);
+ });
+}
+
+/* Runs one validation section with shared startup, logging, and cleanup */
+/* behavior. */
+async function runTestSection(sectionName, tests) {
+ let startedHere = false;
+ try {
+ if (START_SERVER) {
+ startedHere = true;
+ startServer();
+ await waitForServer();
+ }
+
+ log(`\n=== ${sectionName} ===`);
+ for (const test of tests) {
+ log(`RUN ${test.name}`);
+ try {
+ await test();
+ log(`${green("OK")} ${test.name}`);
+ } catch (error) {
+ log(`${red("KO")} ${test.name}`);
+ throw error;
+ }
+ }
+
+ log(green(`${sectionName} passed.`));
+ process.exitCode = 0;
+ } catch (error) {
+ console.error(error && error.stack ? error.stack : error);
+ process.exitCode = 1;
+ } finally {
+ if (startedHere) {
+ await stopManagedServer();
+ try {
+ fs.unlinkSync(BACKUP_PATH);
+ } catch (e) {
+ /* ignore cleanup failures */
+ }
+ }
+ }
+}
+
+module.exports = {
+ BACKUP_PATH,
+ START_SERVER,
+ assert,
+ createMaskedWebSocketFrame,
+ green,
+ log,
+ postJson,
+ postRaw,
+ randomPlayer,
+ runTestSection,
+ sseConnect,
+ startServer,
+ stopManagedServer,
+ wait,
+ waitForServer,
+ wsConnect,
+ fs,
+ crypto,
+ net
+};
+++ /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
- }
- }
-}());
--- /dev/null
+"use strict";
+
+/* -----------------------------------------------------------------------------
+ * HTTP validation section
+ * -----------------------------------------------------------------------------
+ */
+
+const {assert, postJson, postRaw, randomPlayer, runTestSection} = require("./test-common");
+
+/* 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");
+}
+
+/* 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");
+}
+
+/* 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");
+}
+
+runTestSection("HTTP", [
+ testHello,
+ testInvalidJson,
+ testUnknownCommand,
+ testOversizedPostClosesConnection
+]);
--- /dev/null
+"use strict";
+
+/* -----------------------------------------------------------------------------
+ * Persistence validation section
+ * -----------------------------------------------------------------------------
+ */
+
+const {
+ BACKUP_PATH,
+ START_SERVER,
+ assert,
+ fs,
+ postJson,
+ randomPlayer,
+ runTestSection,
+ startServer,
+ stopManagedServer,
+ wait,
+ waitForServer
+} = require("./test-common");
+
+/* 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 not prevent the server from starting. */
+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");
+}
+
+runTestSection("PERSISTENCE", [
+ testStatePersistsToBackupOnShutdown,
+ testBrokenBackupFileDoesNotPreventStartup
+]);
--- /dev/null
+"use strict";
+
+/* -----------------------------------------------------------------------------
+ * Realtime validation section
+ * -----------------------------------------------------------------------------
+ */
+
+const {
+ assert,
+ createMaskedWebSocketFrame,
+ crypto,
+ net,
+ postJson,
+ randomPlayer,
+ runTestSection,
+ sseConnect,
+ wait,
+ wsConnect
+} = require("./test-common");
+
+/* 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");
+}
+
+/* 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: process.env.TRIVABBLE_TEST_HOST || "127.0.0.1", port: parseInt(process.env.TRIVABBLE_TEST_PORT || "3100", 10)}, () => {
+ socket.write(
+ `GET /ws/${encoded} HTTP/1.1\r\n` +
+ `Host: ${process.env.TRIVABBLE_TEST_HOST || "127.0.0.1"}:${parseInt(process.env.TRIVABBLE_TEST_PORT || "3100", 10)}\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");
+}
+
+runTestSection("REALTIME", [
+ testSseReceivesEvents,
+ testWebSocketRoundTrip,
+ testMalformedWebSocketPayload,
+ testInvalidWebSocketUpgradeRejected
+]);
--- /dev/null
+"use strict";
+
+const childProcess = require("child_process");
+const path = require("path");
+const {green, log} = require("./test-common");
+
+/* Runs all validation sections in a stable order so failures are easier to */
+/* localize and section-specific setup stays isolated. */
+const sections = [
+ {name: "HTTP", file: "test-http.js"},
+ {name: "STATE", file: "test-state.js"},
+ {name: "REALTIME", file: "test-realtime.js"},
+ {name: "PERSISTENCE", file: "test-persistence.js"},
+ {name: "STRESS", file: "test-stress-style.js"}
+];
+
+(async function main() {
+ try {
+ for (const section of sections) {
+ const exitCode = await new Promise((resolve, reject) => {
+ const child = childProcess.spawn(process.execPath, [path.join(__dirname, section.file)], {
+ cwd: __dirname,
+ env: process.env,
+ stdio: "inherit"
+ });
+ child.on("error", reject);
+ child.on("exit", (code) => resolve(code === null ? 1 : code));
+ });
+
+ if (exitCode !== 0) {
+ process.exitCode = exitCode;
+ return;
+ }
+ }
+
+ log(green("All tests passed."));
+ process.exitCode = 0;
+ } catch (error) {
+ console.error(error && error.stack ? error.stack : error);
+ process.exitCode = 1;
+ }
+}());
--- /dev/null
+"use strict";
+
+/* -----------------------------------------------------------------------------
+ * State and command validation section
+ * -----------------------------------------------------------------------------
+ */
+
+const {assert, postJson, randomPlayer, runTestSection} = require("./test-common");
+
+/* 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");
+}
+
+/* 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");
+}
+
+/* Oversized chat messages should be accepted but truncated to the */
+/* configured size. */
+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");
+}
+
+/* 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");
+}
+
+/* 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");
+}
+
+/* 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");
+}
+
+/* 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");
+}
+
+runTestSection("STATE", [
+ testJoinAndMove,
+ testMoveValidation,
+ testChangePlayerNameValidation,
+ testSetRackValidation,
+ testLeaveGameRemovesPlayer,
+ testChatTruncation,
+ testChatHistoryRollsOver,
+ testMultiCommandStopsAfterFailure,
+ testChangeBoardResetsState,
+ testScoreAndFlagsUpdateState,
+ testResetGameClearsBoardState
+]);
--- /dev/null
+"use strict";
+
+/* -----------------------------------------------------------------------------
+ * Stress-style validation section
+ * -----------------------------------------------------------------------------
+ */
+
+const {assert, postJson, randomPlayer, runTestSection} = require("./test-common");
+
+/* 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");
+}
+
+runTestSection("STRESS", [
+ stressHttpBurst,
+ stressSequentialGameOps
+]);