forked from eros/curltastic
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:
parent
86153894ac
commit
27e524a418
2
e2e/.gitignore
vendored
Normal file
2
e2e/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# e2e test artifacts
|
||||
node_modules/
|
||||
68
e2e/e2e_end_score.cjs
Normal file
68
e2e/e2e_end_score.cjs
Normal 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
85
e2e/e2e_persistence.cjs
Normal 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
47
e2e/e2e_room_full.cjs
Normal 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
56
e2e/e2e_score.cjs
Normal 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
53
e2e/e2e_test.cjs
Normal 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
33
e2e/load_test.cjs
Normal 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
37
e2e/package-lock.json
generated
Normal 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
15
e2e/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user