From 27e524a418a8b4d73bc599453ae9c9fd3a970818 Mon Sep 17 00:00:00 2001 From: eros Date: Wed, 24 Jun 2026 08:18:16 -0700 Subject: [PATCH] test: add end-to-end WebSocket test suite Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- e2e/.gitignore | 2 + e2e/e2e_end_score.cjs | 68 +++++++++++++++++++++++++++++++++ e2e/e2e_persistence.cjs | 85 +++++++++++++++++++++++++++++++++++++++++ e2e/e2e_room_full.cjs | 47 +++++++++++++++++++++++ e2e/e2e_score.cjs | 56 +++++++++++++++++++++++++++ e2e/e2e_test.cjs | 53 +++++++++++++++++++++++++ e2e/load_test.cjs | 33 ++++++++++++++++ e2e/package-lock.json | 37 ++++++++++++++++++ e2e/package.json | 15 ++++++++ 9 files changed, 396 insertions(+) create mode 100644 e2e/.gitignore create mode 100644 e2e/e2e_end_score.cjs create mode 100644 e2e/e2e_persistence.cjs create mode 100644 e2e/e2e_room_full.cjs create mode 100644 e2e/e2e_score.cjs create mode 100644 e2e/e2e_test.cjs create mode 100644 e2e/load_test.cjs create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..5b28233 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,2 @@ +# e2e test artifacts +node_modules/ diff --git a/e2e/e2e_end_score.cjs b/e2e/e2e_end_score.cjs new file mode 100644 index 0000000..21f2f50 --- /dev/null +++ b/e2e/e2e_end_score.cjs @@ -0,0 +1,68 @@ +const WebSocket = require('ws') + +const base = (room) => `ws://127.0.0.1:3000/ws?room=${room}` + +function connect(name, room) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(base(room)) + const messages = [] + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) + messages.push(msg) + if (msg.type === 'joined') resolve({ ws, messages, team: msg.team }) + }) + ws.on('error', reject) + }) +} + +function waitFor(messages, pred, timeout = 30000) { + const start = Date.now() + return new Promise((resolve, reject) => { + const check = () => { + if (pred()) return resolve(undefined) + if (Date.now() - start > timeout) return reject(new Error('timeout')) + setTimeout(check, 50) + } + check() + }) +} + +;(async () => { + const room = 'ENDQA' + Math.floor(Math.random() * 1000) + const p1 = await connect('p1', room) + const p2 = await connect('p2', room) + await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000) + + const getTurn = () => { + const st = p1.messages.slice(-1)[0] + return st && st.type === 'game_state' ? st.turn_team : null + } + + let stateCount = p1.messages.filter(m => m.type === 'game_state').length + + for (let i = 0; i < 16; i++) { + await waitFor(p1.messages, () => { + const last = p1.messages.slice(-1)[0] + return last && last.type === 'game_state' && last.phase === 'playing' + }, 5000) + const turn = getTurn() + if (!turn) throw new Error('no turn') + const thrower = turn === p1.team ? p1 : p2 + const broomX = (Math.random() - 0.5) * 0.6 + thrower.ws.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) + const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length + await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'game_state').length > prevStateCount, 15000) + } + + await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'end_scored'), 20000) + const final = p1.messages.slice(-1)[0] + console.log('Final game_state:', final) + if (final.end <= 1) throw new Error('end did not advance') + console.log('End scored event received; end advanced to', final.end) + p1.ws.close() + p2.ws.close() + process.exit(0) +})().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/e2e/e2e_persistence.cjs b/e2e/e2e_persistence.cjs new file mode 100644 index 0000000..61d31de --- /dev/null +++ b/e2e/e2e_persistence.cjs @@ -0,0 +1,85 @@ +const WebSocket = require('ws') + +const room = 'PERSIST' + Math.floor(Math.random() * 1000) +const base = 'ws://127.0.0.1:3000/ws?room=' + room + +function connect(name) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(base) + const messages = [] + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) + messages.push(msg) + console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team} stones=${msg.stones.length}` : '') + if (msg.type === 'joined') { + resolve({ ws, messages, team: msg.team }) + } + }) + ws.on('open', () => console.log(`[${name}] open`)) + ws.on('error', (e) => { console.error(`[${name}] error`, e.message); reject(e) }) + ws.on('close', (code) => console.log(`[${name}] close`, code)) + }) +} + +async function waitFor(condFn, timeout = 5000) { + const start = Date.now() + while (!condFn()) { + if (Date.now() - start > timeout) throw new Error('Timeout waiting') + await new Promise(r => setTimeout(r, 50)) + } +} + +function latestState(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'game_state') return messages[i] + } + return null +} + +;(async () => { + const p1 = await connect('p1') + await waitFor(() => p1.messages.some(m => m.type === 'waiting')) + const p2 = await connect('p2') + await waitFor(() => p2.messages.some(m => m.type === 'game_state')) + + // Determine the current player and throw two stones in the same end. + let state = latestState(p1.messages) + console.log('Initial state', state) + + // First throw: yellow aims slightly left of center. + let turn = state.turn_team + let turnPlayer = turn === p1.team ? p1 : p2 + turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) + await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000) + + state = latestState(p1.messages) + console.log('After first throw:', state) + + // Second throw: red aims slightly right of center. + turn = state.turn_team + turnPlayer = turn === p1.team ? p1 : p2 + turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 })) + await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000) + + state = latestState(p1.messages) + console.log('After second throw:', state) + + if (state.stones.length !== 2) throw new Error(`Expected 2 stones after second throw, got ${state.stones.length}`) + + // Check that ids are monotonic. + const ids = state.stones.map(s => s.id).sort((a, b) => a - b) + if (ids[0] !== 1 || ids[1] !== 2) throw new Error(`Unexpected stone ids: ${ids}`) + + // Both stones should be in play (near house). + for (const s of state.stones) { + if (s.y < 35 || s.y > 42) throw new Error(`Stone ${s.id} is out of house: y=${s.y}`) + } + + console.log('PERSISTENCE E2E PASSED') + p1.ws.close() + p2.ws.close() + process.exit(0) +})().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/e2e/e2e_room_full.cjs b/e2e/e2e_room_full.cjs new file mode 100644 index 0000000..8ff8269 --- /dev/null +++ b/e2e/e2e_room_full.cjs @@ -0,0 +1,47 @@ +const WebSocket = require('ws') + +const base = (room) => `ws://127.0.0.1:3000/ws?room=${room}` + +function connect(name, room) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(base(room)) + const messages = [] + ws.on('open', () => resolve({ ws, messages })) + ws.on('message', (data) => messages.push(JSON.parse(data.toString()))) + ws.on('error', reject) + }) +} + +function waitFor(messages, pred, timeout = 10000) { + const start = Date.now() + return new Promise((resolve, reject) => { + const check = () => { + if (pred()) return resolve(undefined) + if (Date.now() - start > timeout) return reject(new Error('timeout')) + setTimeout(check, 50) + } + check() + }) +} + +;(async () => { + const room = 'ROOMFULL' + Math.floor(Math.random() * 1000) + const p1 = await connect('p1', room) + const p2 = await connect('p2', room) + await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000) + const p3 = await connect('p3', room) + await waitFor(p3.messages, () => p3.messages.some(m => m.type === 'error'), 2000) + const p3error = p3.messages.some(m => m.type === 'error' && m.message.includes('Room is full')) + const p1error = p1.messages.some(m => m.type === 'error' && m.message.includes('Room is full')) + const p2error = p2.messages.some(m => m.type === 'error' && m.message.includes('Room is full')) + console.log({ p3error, p1error, p2error }) + if (!p3error) throw new Error('p3 should get room full error') + if (p1error || p2error) throw new Error('existing players should not see room-full error') + p1.ws.close() + p2.ws.close() + p3.ws.close() + process.exit(0) +})().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/e2e/e2e_score.cjs b/e2e/e2e_score.cjs new file mode 100644 index 0000000..de2354f --- /dev/null +++ b/e2e/e2e_score.cjs @@ -0,0 +1,56 @@ +const WebSocket = require('ws') + +const base = 'ws://127.0.0.1:3000/ws?room=SCOREQA' + +function connect(name) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(base) + const messages = [] + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) + messages.push(msg) + if (msg.type === 'joined') resolve({ ws, messages, team: msg.team }) + }) + ws.on('error', reject) + }) +} + +function waitFor(condFn, timeout = 5000) { + const start = Date.now() + return new Promise((resolve, reject) => { + const check = () => { + if (condFn()) return resolve(undefined) + if (Date.now() - start > timeout) return reject(new Error('timeout')) + setTimeout(check, 50) + } + check() + }) +} + +;(async () => { + const p1 = await connect('p1') + const p2 = await connect('p2') + await waitFor(() => p1.messages.some(m => m.type === 'game_state')) + let state = p1.messages.find(m => m.type === 'game_state') + console.log('start turn', state.turn_team, 'hammer', state.hammer) + + const thrower1 = state.turn_team === p1.team ? p1 : p2 + thrower1.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) + await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000) + await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) + await new Promise(r => setTimeout(r, 500)) + state = p1.messages.slice(-1)[0] + console.log('After first throw:', state) + const thrower2 = state.turn_team === p1.team ? p1 : p2 + thrower2.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 })) + await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000) + await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) + await new Promise(r => setTimeout(r, 500)) + console.log('Final stones', p1.messages.slice(-1)[0].stones) + p1.ws.close() + p2.ws.close() + process.exit(0) +})().catch(e => { + console.error(e) + process.exit(1) +}) diff --git a/e2e/e2e_test.cjs b/e2e/e2e_test.cjs new file mode 100644 index 0000000..dce482d --- /dev/null +++ b/e2e/e2e_test.cjs @@ -0,0 +1,53 @@ +const WebSocket = require('ws') + +const room = 'QA' + Math.floor(Math.random() * 1000) +const base = 'ws://127.0.0.1:3000/ws?room=' + room + +function connect(name) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(base) + const messages = [] + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) + messages.push(msg) + console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team}` : '') + if (msg.type === 'joined') { + resolve({ ws, messages, team: msg.team }) + } + }) + ws.on('open', () => console.log(`[${name}] open`)) + ws.on('error', (e) => { console.error(`[${name}] error`, e.message); reject(e) }) + ws.on('close', (code) => console.log(`[${name}] close`, code)) + }) +} + +async function waitFor(condFn, timeout = 5000) { + const start = Date.now() + while (!condFn()) { + if (Date.now() - start > timeout) throw new Error('Timeout waiting') + await new Promise(r => setTimeout(r, 50)) + } +} + +;(async () => { + const p1 = await connect('p1') + await waitFor(() => p1.messages.some(m => m.type === 'waiting')) + const p2 = await connect('p2') + await waitFor(() => p2.messages.some(m => m.type === 'joined')) + await waitFor(() => p1.messages.some(m => m.type === 'game_state')) + const state = p1.messages.find(m => m.type === 'game_state') + console.log('Game state', state) + + const turn = state.turn_team + const turnPlayer = turn === p1.team ? p1 : p2 + turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) + await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000) + await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) + console.log('Final state after throw:', p1.messages.slice(-1)[0]) + p1.ws.close() + p2.ws.close() + process.exit(0) +})().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/e2e/load_test.cjs b/e2e/load_test.cjs new file mode 100644 index 0000000..5162bff --- /dev/null +++ b/e2e/load_test.cjs @@ -0,0 +1,33 @@ +const WebSocket = require('ws') + +const ROOMS = 10 +const BASE = 'ws://127.0.0.1:3000/ws?room=' + +function connect(name, room) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(BASE + room) + ws.on('open', () => resolve(ws)) + ws.on('error', reject) + ws.on('message', () => {}) + }) +} + +async function runRoom(i) { + const room = `LOAD${i}` + const p1 = await connect('p1', room) + const p2 = await connect('p2', room) + await new Promise(r => setTimeout(r, 200)) + p1.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 5, curl: 1, friction: 1.0 })) + await new Promise(r => setTimeout(r, 800)) + p2.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 5, curl: 1, friction: 1.0 })) + await new Promise(r => setTimeout(r, 1000)) + p1.close() + p2.close() +} + +;(async () => { + const start = Date.now() + await Promise.all(Array.from({ length: ROOMS }, (_, i) => runRoom(i))) + console.log(`10 rooms played start-to-throw-to-close in ${Date.now() - start}ms`) + process.exit(0) +})() diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..01be623 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "e2e", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "ws": "^8.21.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..f18161f --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "e2e", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "ws": "^8.21.0" + } +}