});
}
+/* Sends one raw HTTP request to an arbitrary path and returns the full */
+/* response. */
+function requestRaw(method, requestPath, body = "", headers = {}) {
+ return new Promise((resolve, reject) => {
+ const req = http.request({
+ host: HOST,
+ port: PORT,
+ path: requestPath,
+ method,
+ headers: {
+ ...headers,
+ ...(body ? {"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);
+ if (body) {
+ req.write(body);
+ }
+ req.end();
+ });
+}
+
/* Opens one server-sent-events stream using the server's URL-encoded */
/* payload format. */
function sseConnect(initialPayload) {
});
}
+/* Reads the first SSE chunk, then closes the request to avoid hanging tests. */
+function readFirstSseChunk(initialPayload) {
+ return new Promise((resolve, reject) => {
+ sseConnect(initialPayload).then(({req, res}) => {
+ let settled = false;
+
+ function finish(value) {
+ if (!settled) {
+ settled = true;
+ try {
+ req.destroy();
+ } catch (e) {
+ /* ignore socket shutdown errors */
+ }
+ resolve({
+ statusCode: res.statusCode,
+ headers: res.headers,
+ chunk: value
+ });
+ }
+ }
+
+ res.once("data", (chunk) => finish(chunk));
+ res.once("error", reject);
+
+ setTimeout(() => finish(""), 1000);
+ }, reject);
+ });
+}
+
+/* Collects SSE chunks for a short time window, then closes the request. */
+function collectSseChunks(initialPayload, durationMs = 500) {
+ return new Promise((resolve, reject) => {
+ sseConnect(initialPayload).then(({req, res}) => {
+ let data = "";
+ let settled = false;
+
+ function finish() {
+ if (!settled) {
+ settled = true;
+ try {
+ req.destroy();
+ } catch (e) {
+ /* ignore socket shutdown errors */
+ }
+ resolve({
+ statusCode: res.statusCode,
+ headers: res.headers,
+ body: data
+ });
+ }
+ }
+
+ res.on("data", (chunk) => {
+ data += chunk;
+ });
+ res.once("error", reject);
+
+ setTimeout(finish, durationMs);
+ }, reject);
+ });
+}
+
/* Client websocket frames must be masked, so tests build frames manually. */
function createMaskedWebSocketFrame(data) {
const payload = Buffer.from(data, "utf8");
log,
postJson,
postRaw,
+ requestRaw,
+ readFirstSseChunk,
+ collectSseChunks,
randomPlayer,
runTestSection,
sseConnect,
* -----------------------------------------------------------------------------
*/
-const {assert, postJson, postRaw, randomPlayer, runTestSection} = require("./test-common");
+const {assert, postJson, postRaw, randomPlayer, readFirstSseChunk, requestRaw, runTestSection} = require("./test-common");
/* Basic health check for the main JSON command endpoint. */
async function testHello() {
assert(body[0].reason === "Unknown command", "unknown command should report the correct reason");
}
+/* SSE GET requests should switch to event-stream mode and emit the initial */
+/* state. */
+async function testSseEndpointReturnsEventStream() {
+ const playerName = randomPlayer("sse-http");
+ const gameNumber = `sse-http-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
+
+ const res = await readFirstSseChunk({playerName, gameNumber});
+
+ assert(res.statusCode === 200, "SSE endpoint should return HTTP 200");
+ assert((res.headers["content-type"] || "").includes("text/event-stream"), "SSE endpoint should return event-stream content type");
+ assert(res.chunk.includes("data:"), "SSE endpoint should emit an initial data frame");
+ assert(res.chunk.includes(`"playerName":"${playerName}"`), "SSE initial payload should include the player name");
+}
+
+/* Invalid JSON delivered through the SSE URL should still be rejected */
+/* cleanly. */
+async function testSseEndpointRejectsInvalidJson() {
+ const res = await requestRaw("GET", "/sse/%7Bnot-json");
+ assert(res.statusCode === 400, "invalid SSE JSON should return 400");
+ assert(res.body.includes("Unable to parse your JSON data"), "invalid SSE JSON should explain the parse error");
+}
+
/* Oversized request bodies should be rejected by closing the connection. */
async function testOversizedPostClosesConnection() {
const body = "x".repeat(1000001);
testHello,
testInvalidJson,
testUnknownCommand,
+ testSseEndpointReturnsEventStream,
+ testSseEndpointRejectsInvalidJson,
testOversizedPostClosesConnection
]);
createMaskedWebSocketFrame,
crypto,
net,
+ collectSseChunks,
postJson,
randomPlayer,
+ readFirstSseChunk,
runTestSection,
- sseConnect,
wait,
wsConnect
} = require("./test-common");
const listener = randomPlayer("listener");
const actor = randomPlayer("actor");
- const connection = await sseConnect({playerName: listener, gameNumber});
- let stream = "";
- connection.res.on("data", (chunk) => {
- stream += chunk;
- });
+ const initial = await readFirstSseChunk({playerName: listener, gameNumber});
+ assert(initial.statusCode === 200, "SSE stream should return HTTP 200");
+ assert(initial.chunk.includes("data:"), "SSE stream should emit an initial data frame");
+ assert(initial.chunk.includes(`"playerName":"${listener}"`), "SSE initial frame should include the listener name");
+
+ const streamPromise = collectSseChunks({playerName: listener, gameNumber}, 700);
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");
+ const stream = await streamPromise;
+ assert(stream.body.includes("data:"), "SSE stream should contain data frames");
+ assert(stream.body.includes("players"), "SSE stream should include player event data");
}
/* WebSocket clients should be able to send a command and receive a reply. */