test: add end-to-end WebSocket test suite

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
eros 2026-06-24 08:18:16 -07:00
parent 86153894ac
commit 27e524a418
9 changed files with 396 additions and 0 deletions

2
e2e/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# e2e test artifacts
node_modules/

68
e2e/e2e_end_score.cjs Normal file
View File

@ -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)
})

85
e2e/e2e_persistence.cjs Normal file
View File

@ -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)
})

47
e2e/e2e_room_full.cjs Normal file
View File

@ -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)
})

56
e2e/e2e_score.cjs Normal file
View File

@ -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)
})

53
e2e/e2e_test.cjs Normal file
View File

@ -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)
})

33
e2e/load_test.cjs Normal file
View File

@ -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)
})()

37
e2e/package-lock.json generated Normal file
View File

@ -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
}
}
}
}
}

15
e2e/package.json Normal file
View File

@ -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"
}
}