feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)

## Server Testing Utils
- Add TestServer, TestClient, MockRoom for unit testing
- Export testing utilities from @esengine/server/testing

## Transaction Storage (BREAKING)
- Simplify RedisStorage/MongoStorage to factory pattern only
- Remove DI client injection option
- Add lazy connection and Symbol.asyncDispose support
- Add 161 unit tests with full coverage

## Pathfinding Tests
- Add 150 unit tests covering all components
- BinaryHeap, Heuristics, AStarPathfinder, GridMap, NavMesh, PathSmoother

## Docs
- Update storage.md for new factory pattern API
This commit is contained in:
YHH
2025-12-29 15:02:13 +08:00
committed by GitHub
parent 10c3891abd
commit 3b978384c7
50 changed files with 7591 additions and 660 deletions

View File

@@ -10,6 +10,10 @@
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./testing": {
"import": "./dist/testing/index.js",
"types": "./dist/testing/index.d.ts"
}
},
"files": [
@@ -21,7 +25,9 @@
"build:watch": "tsup --watch",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
"clean": "rimraf dist",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@esengine/rpc": "workspace:*"
@@ -35,6 +41,7 @@
"rimraf": "^5.0.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^2.0.0",
"ws": "^8.18.0"
},
"publishConfig": {

View File

@@ -197,9 +197,9 @@ export class RoomManager {
)
}
private _findAvailableRoom(name: string): Room | undefined {
private _findAvailableRoom(name: string): Room | null {
const def = this._definitions.get(name)
if (!def) return undefined
if (!def) return null
for (const room of this._rooms.values()) {
if (
@@ -212,7 +212,7 @@ export class RoomManager {
}
}
return undefined
return null
}
private _generateRoomId(): string {

View File

@@ -0,0 +1,134 @@
/**
* @zh 模拟房间
* @en Mock room for testing
*/
import { Room, onMessage, type Player } from '../room/index.js'
/**
* @zh 模拟房间状态
* @en Mock room state
*/
export interface MockRoomState {
messages: Array<{ type: string; data: unknown; playerId: string }>
joinCount: number
leaveCount: number
}
/**
* @zh 模拟房间
* @en Mock room for testing
*
* @zh 记录所有事件和消息,用于测试断言
* @en Records all events and messages for test assertions
*
* @example
* ```typescript
* const env = await createTestEnv()
* env.server.define('mock', MockRoom)
*
* const client = await env.createClient()
* await client.joinRoom('mock')
*
* client.sendToRoom('Test', { value: 123 })
* await wait(50)
*
* // MockRoom 会广播收到的消息
* const msg = client.getLastMessage('RoomMessage')
* ```
*/
export class MockRoom extends Room<MockRoomState> {
state: MockRoomState = {
messages: [],
joinCount: 0,
leaveCount: 0,
}
onCreate(): void {
// 房间创建
}
onJoin(player: Player): void {
this.state.joinCount++
this.broadcast('PlayerJoined', {
playerId: player.id,
joinCount: this.state.joinCount,
})
}
onLeave(player: Player): void {
this.state.leaveCount++
this.broadcast('PlayerLeft', {
playerId: player.id,
leaveCount: this.state.leaveCount,
})
}
@onMessage('*')
handleAnyMessage(data: unknown, player: Player, type: string): void {
this.state.messages.push({
type,
data,
playerId: player.id,
})
// 回显消息给所有玩家
this.broadcast('MessageReceived', {
type,
data,
from: player.id,
})
}
@onMessage('Echo')
handleEcho(data: unknown, player: Player): void {
// 只回复给发送者
player.send('EchoReply', data)
}
@onMessage('Broadcast')
handleBroadcast(data: unknown, _player: Player): void {
this.broadcast('BroadcastMessage', data)
}
@onMessage('Ping')
handlePing(_data: unknown, player: Player): void {
player.send('Pong', { timestamp: Date.now() })
}
}
/**
* @zh 简单回显房间
* @en Simple echo room
*
* @zh 将收到的任何消息回显给发送者
* @en Echoes any received message back to sender
*/
export class EchoRoom extends Room {
@onMessage('*')
handleAnyMessage(data: unknown, player: Player, type: string): void {
player.send(type, data)
}
}
/**
* @zh 广播房间
* @en Broadcast room
*
* @zh 将收到的任何消息广播给所有玩家
* @en Broadcasts any received message to all players
*/
export class BroadcastRoom extends Room {
onJoin(player: Player): void {
this.broadcast('PlayerJoined', { id: player.id })
}
onLeave(player: Player): void {
this.broadcast('PlayerLeft', { id: player.id })
}
@onMessage('*')
handleAnyMessage(data: unknown, player: Player, type: string): void {
this.broadcast(type, { from: player.id, data })
}
}

View File

@@ -0,0 +1,371 @@
/**
* @zh 房间测试示例
* @en Room test examples
*
* @zh 这个文件展示了如何使用测试工具进行服务器测试
* @en This file demonstrates how to use testing utilities for server testing
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createTestEnv, type TestEnvironment, wait } from './TestServer.js'
import { MockRoom, BroadcastRoom } from './MockRoom.js'
import { Room, onMessage, type Player } from '../room/index.js'
// ============================================================================
// Custom Room for Testing | 自定义测试房间
// ============================================================================
interface GameState {
players: Map<string, { x: number; y: number }>
scores: Map<string, number>
}
class GameRoom extends Room<GameState> {
maxPlayers = 4
state: GameState = {
players: new Map(),
scores: new Map(),
}
onJoin(player: Player): void {
this.state.players.set(player.id, { x: 0, y: 0 })
this.state.scores.set(player.id, 0)
this.broadcast('PlayerJoined', {
playerId: player.id,
playerCount: this.state.players.size,
})
}
onLeave(player: Player): void {
this.state.players.delete(player.id)
this.state.scores.delete(player.id)
this.broadcast('PlayerLeft', {
playerId: player.id,
playerCount: this.state.players.size,
})
}
@onMessage('Move')
handleMove(data: { x: number; y: number }, player: Player): void {
const pos = this.state.players.get(player.id)
if (pos) {
pos.x = data.x
pos.y = data.y
this.broadcast('PlayerMoved', {
playerId: player.id,
x: data.x,
y: data.y,
})
}
}
@onMessage('Score')
handleScore(data: { points: number }, player: Player): void {
const current = this.state.scores.get(player.id) ?? 0
this.state.scores.set(player.id, current + data.points)
player.send('ScoreUpdated', {
score: this.state.scores.get(player.id),
})
}
}
// ============================================================================
// Test Suites | 测试套件
// ============================================================================
describe('Room Integration Tests', () => {
let env: TestEnvironment
beforeEach(async () => {
env = await createTestEnv()
})
afterEach(async () => {
await env.cleanup()
})
// ========================================================================
// Basic Tests | 基础测试
// ========================================================================
describe('Basic Room Operations', () => {
it('should create and join room', async () => {
env.server.define('game', GameRoom)
const client = await env.createClient()
const result = await client.joinRoom('game')
expect(result.roomId).toBeDefined()
expect(result.playerId).toBeDefined()
expect(client.roomId).toBe(result.roomId)
})
it('should leave room', async () => {
env.server.define('game', GameRoom)
const client = await env.createClient()
await client.joinRoom('game')
await client.leaveRoom()
expect(client.roomId).toBeNull()
})
it('should join existing room by id', async () => {
env.server.define('game', GameRoom)
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('game')
const client2 = await env.createClient()
const result = await client2.joinRoomById(roomId)
expect(result.roomId).toBe(roomId)
})
})
// ========================================================================
// Message Tests | 消息测试
// ========================================================================
describe('Room Messages', () => {
it('should receive room messages', async () => {
env.server.define('game', GameRoom)
const client = await env.createClient()
await client.joinRoom('game')
const movePromise = client.waitForRoomMessage('PlayerMoved')
client.sendToRoom('Move', { x: 100, y: 200 })
const msg = await movePromise
expect(msg).toEqual({
playerId: client.playerId,
x: 100,
y: 200,
})
})
it('should receive broadcast messages', async () => {
env.server.define('game', GameRoom)
const [client1, client2] = await env.createClients(2)
const { roomId } = await client1.joinRoom('game')
await client2.joinRoomById(roomId)
// client1 等待收到 client2 的移动消息
const movePromise = client1.waitForRoomMessage('PlayerMoved')
client2.sendToRoom('Move', { x: 50, y: 75 })
const msg = await movePromise
expect(msg).toMatchObject({
playerId: client2.playerId,
x: 50,
y: 75,
})
})
it('should handle player join/leave broadcasts', async () => {
env.server.define('broadcast', BroadcastRoom)
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('broadcast')
// 等待 client2 加入的广播
const joinPromise = client1.waitForRoomMessage<{ id: string }>('PlayerJoined')
const client2 = await env.createClient()
const client2Result = await client2.joinRoomById(roomId)
const joinMsg = await joinPromise
expect(joinMsg).toMatchObject({ id: client2Result.playerId })
// 等待 client2 离开的广播
const leavePromise = client1.waitForRoomMessage<{ id: string }>('PlayerLeft')
const client2PlayerId = client2.playerId // 保存 playerId
await client2.leaveRoom()
const leaveMsg = await leavePromise
expect(leaveMsg).toMatchObject({ id: client2PlayerId })
})
})
// ========================================================================
// MockRoom Tests | 模拟房间测试
// ========================================================================
describe('MockRoom', () => {
it('should record messages', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
// 使用 Echo 消息,因为它是明确定义的
const echoPromise = client.waitForRoomMessage('EchoReply')
client.sendToRoom('Echo', { value: 123 })
await echoPromise
expect(client.hasReceivedMessage('RoomMessage')).toBe(true)
})
it('should handle echo', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
const echoPromise = client.waitForRoomMessage('EchoReply')
client.sendToRoom('Echo', { message: 'hello' })
const reply = await echoPromise
expect(reply).toEqual({ message: 'hello' })
})
it('should handle ping/pong', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
client.sendToRoom('Ping', {})
const pong = await pongPromise
expect(pong.timestamp).toBeGreaterThan(0)
})
})
// ========================================================================
// Multiple Clients Tests | 多客户端测试
// ========================================================================
describe('Multiple Clients', () => {
it('should handle multiple clients in same room', async () => {
env.server.define('game', GameRoom)
const clients = await env.createClients(3)
const { roomId } = await clients[0].joinRoom('game')
for (let i = 1; i < clients.length; i++) {
await clients[i].joinRoomById(roomId)
}
// 所有客户端都应该能收到消息
const promises = clients.map((c) => c.waitForRoomMessage('PlayerMoved'))
clients[0].sendToRoom('Move', { x: 1, y: 2 })
const results = await Promise.all(promises)
for (const result of results) {
expect(result).toMatchObject({ x: 1, y: 2 })
}
})
it('should handle concurrent room operations', async () => {
env.server.define('game', GameRoom)
const clients = await env.createClients(4) // maxPlayers = 4
// 顺序加入房间(避免并发创建多个房间)
const { roomId } = await clients[0].joinRoom('game')
// 其余客户端加入同一房间
const results = await Promise.all(
clients.slice(1).map((c) => c.joinRoomById(roomId))
)
// 验证所有客户端都在同一房间
for (const result of results) {
expect(result.roomId).toBe(roomId)
}
})
})
// ========================================================================
// Error Handling Tests | 错误处理测试
// ========================================================================
describe('Error Handling', () => {
it('should reject joining non-existent room type', async () => {
const client = await env.createClient()
await expect(client.joinRoom('nonexistent')).rejects.toThrow()
})
it('should handle client disconnect gracefully', async () => {
env.server.define('game', GameRoom)
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('game')
const client2 = await env.createClient()
await client2.joinRoomById(roomId)
// 等待 client2 离开的广播
const leavePromise = client1.waitForRoomMessage('PlayerLeft')
// 强制断开 client2
await client2.disconnect()
// client1 应该收到离开消息
const msg = await leavePromise
expect(msg).toBeDefined()
})
})
// ========================================================================
// Assertion Helpers Tests | 断言辅助测试
// ========================================================================
describe('TestClient Assertions', () => {
it('should track received messages', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
// 发送多条消息
client.sendToRoom('Test', { n: 1 })
client.sendToRoom('Test', { n: 2 })
client.sendToRoom('Test', { n: 3 })
// 等待消息处理
await wait(100)
expect(client.getMessageCount()).toBeGreaterThan(0)
expect(client.hasReceivedMessage('RoomMessage')).toBe(true)
})
it('should get messages of specific type', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
client.sendToRoom('Ping', {})
await client.waitForRoomMessage('Pong')
const pongs = client.getMessagesOfType('RoomMessage')
expect(pongs.length).toBeGreaterThan(0)
})
it('should clear message history', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
client.sendToRoom('Test', {})
await wait(50)
expect(client.getMessageCount()).toBeGreaterThan(0)
client.clearMessages()
expect(client.getMessageCount()).toBe(0)
})
})
})

View File

@@ -0,0 +1,523 @@
/**
* @zh 测试客户端
* @en Test client for server testing
*/
import WebSocket from 'ws'
import { json } from '@esengine/rpc/codec'
import type { Codec } from '@esengine/rpc/codec'
// ============================================================================
// Types | 类型定义
// ============================================================================
/**
* @zh 测试客户端配置
* @en Test client options
*/
export interface TestClientOptions {
/**
* @zh 编解码器
* @en Codec
* @defaultValue json()
*/
codec?: Codec
/**
* @zh API 调用超时(毫秒)
* @en API call timeout in milliseconds
* @defaultValue 5000
*/
timeout?: number
/**
* @zh 连接超时(毫秒)
* @en Connection timeout in milliseconds
* @defaultValue 5000
*/
connectTimeout?: number
}
/**
* @zh 房间加入结果
* @en Room join result
*/
export interface JoinRoomResult {
roomId: string
playerId: string
}
/**
* @zh 收到的消息记录
* @en Received message record
*/
export interface ReceivedMessage {
type: string
data: unknown
timestamp: number
}
// ============================================================================
// Constants | 常量
// ============================================================================
const PacketType = {
ApiRequest: 0,
ApiResponse: 1,
ApiError: 2,
Message: 3,
} as const
// ============================================================================
// TestClient Class | 测试客户端类
// ============================================================================
interface PendingCall {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
/**
* @zh 测试客户端
* @en Test client for server integration testing
*
* @zh 专为测试设计的客户端,提供便捷的断言方法和消息记录功能
* @en Client designed for testing, with convenient assertion methods and message recording
*
* @example
* ```typescript
* const client = new TestClient(3000)
* await client.connect()
*
* // 加入房间
* const { roomId } = await client.joinRoom('game')
*
* // 发送消息
* client.sendToRoom('Move', { x: 10, y: 20 })
*
* // 等待收到特定消息
* const msg = await client.waitForMessage('PlayerMoved')
*
* // 断言收到消息
* expect(client.hasReceivedMessage('PlayerMoved')).toBe(true)
*
* await client.disconnect()
* ```
*/
export class TestClient {
private readonly _port: number
private readonly _codec: Codec
private readonly _timeout: number
private readonly _connectTimeout: number
private _ws: WebSocket | null = null
private _callIdCounter = 0
private _connected = false
private _currentRoomId: string | null = null
private _currentPlayerId: string | null = null
private readonly _pendingCalls = new Map<number, PendingCall>()
private readonly _msgHandlers = new Map<string, Set<(data: unknown) => void>>()
private readonly _receivedMessages: ReceivedMessage[] = []
constructor(port: number, options: TestClientOptions = {}) {
this._port = port
this._codec = options.codec ?? json()
this._timeout = options.timeout ?? 5000
this._connectTimeout = options.connectTimeout ?? 5000
}
// ========================================================================
// Properties | 属性
// ========================================================================
/**
* @zh 是否已连接
* @en Whether connected
*/
get isConnected(): boolean {
return this._connected
}
/**
* @zh 当前房间 ID
* @en Current room ID
*/
get roomId(): string | null {
return this._currentRoomId
}
/**
* @zh 当前玩家 ID
* @en Current player ID
*/
get playerId(): string | null {
return this._currentPlayerId
}
/**
* @zh 收到的所有消息
* @en All received messages
*/
get receivedMessages(): ReadonlyArray<ReceivedMessage> {
return this._receivedMessages
}
// ========================================================================
// Connection | 连接管理
// ========================================================================
/**
* @zh 连接到服务器
* @en Connect to server
*/
connect(): Promise<this> {
return new Promise((resolve, reject) => {
const url = `ws://localhost:${this._port}`
this._ws = new WebSocket(url)
const timeout = setTimeout(() => {
this._ws?.close()
reject(new Error(`Connection timeout after ${this._connectTimeout}ms`))
}, this._connectTimeout)
this._ws.on('open', () => {
clearTimeout(timeout)
this._connected = true
resolve(this)
})
this._ws.on('close', () => {
this._connected = false
this._rejectAllPending('Connection closed')
})
this._ws.on('error', (err) => {
clearTimeout(timeout)
if (!this._connected) {
reject(err)
}
})
this._ws.on('message', (data: Buffer) => {
this._handleMessage(data)
})
})
}
/**
* @zh 断开连接
* @en Disconnect from server
*/
async disconnect(): Promise<void> {
return new Promise((resolve) => {
if (!this._ws || this._ws.readyState === WebSocket.CLOSED) {
resolve()
return
}
this._ws.once('close', () => {
this._connected = false
this._ws = null
resolve()
})
this._ws.close()
})
}
// ========================================================================
// Room Operations | 房间操作
// ========================================================================
/**
* @zh 加入房间
* @en Join a room
*/
async joinRoom(roomType: string, options?: Record<string, unknown>): Promise<JoinRoomResult> {
const result = await this.call<JoinRoomResult>('JoinRoom', { roomType, options })
this._currentRoomId = result.roomId
this._currentPlayerId = result.playerId
return result
}
/**
* @zh 通过 ID 加入房间
* @en Join a room by ID
*/
async joinRoomById(roomId: string): Promise<JoinRoomResult> {
const result = await this.call<JoinRoomResult>('JoinRoom', { roomId })
this._currentRoomId = result.roomId
this._currentPlayerId = result.playerId
return result
}
/**
* @zh 离开房间
* @en Leave room
*/
async leaveRoom(): Promise<void> {
await this.call('LeaveRoom', {})
this._currentRoomId = null
this._currentPlayerId = null
}
/**
* @zh 发送消息到房间
* @en Send message to room
*/
sendToRoom(type: string, data: unknown): void {
this.send('RoomMessage', { type, data })
}
// ========================================================================
// API Calls | API 调用
// ========================================================================
/**
* @zh 调用 API
* @en Call API
*/
call<T = unknown>(name: string, input: unknown): Promise<T> {
return new Promise((resolve, reject) => {
if (!this._connected || !this._ws) {
reject(new Error('Not connected'))
return
}
const id = ++this._callIdCounter
const timer = setTimeout(() => {
this._pendingCalls.delete(id)
reject(new Error(`API call '${name}' timeout after ${this._timeout}ms`))
}, this._timeout)
this._pendingCalls.set(id, {
resolve: resolve as (v: unknown) => void,
reject,
timer,
})
const packet = [PacketType.ApiRequest, id, name, input]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this._ws.send(this._codec.encode(packet as any) as Buffer)
})
}
/**
* @zh 发送消息
* @en Send message
*/
send(name: string, data: unknown): void {
if (!this._connected || !this._ws) return
const packet = [PacketType.Message, name, data]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this._ws.send(this._codec.encode(packet as any) as Buffer)
}
// ========================================================================
// Message Handling | 消息处理
// ========================================================================
/**
* @zh 监听消息
* @en Listen for message
*/
on(name: string, handler: (data: unknown) => void): this {
let handlers = this._msgHandlers.get(name)
if (!handlers) {
handlers = new Set()
this._msgHandlers.set(name, handlers)
}
handlers.add(handler)
return this
}
/**
* @zh 取消监听消息
* @en Remove message listener
*/
off(name: string, handler?: (data: unknown) => void): this {
if (handler) {
this._msgHandlers.get(name)?.delete(handler)
} else {
this._msgHandlers.delete(name)
}
return this
}
/**
* @zh 等待收到指定消息
* @en Wait for a specific message
*/
waitForMessage<T = unknown>(type: string, timeout?: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutMs = timeout ?? this._timeout
const timer = setTimeout(() => {
this.off(type, handler)
reject(new Error(`Timeout waiting for message '${type}' after ${timeoutMs}ms`))
}, timeoutMs)
const handler = (data: unknown) => {
clearTimeout(timer)
this.off(type, handler)
resolve(data as T)
}
this.on(type, handler)
})
}
/**
* @zh 等待收到指定房间消息
* @en Wait for a specific room message
*/
waitForRoomMessage<T = unknown>(type: string, timeout?: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutMs = timeout ?? this._timeout
const timer = setTimeout(() => {
this.off('RoomMessage', handler)
reject(new Error(`Timeout waiting for room message '${type}' after ${timeoutMs}ms`))
}, timeoutMs)
const handler = (data: unknown) => {
const msg = data as { type: string; data: unknown }
if (msg.type === type) {
clearTimeout(timer)
this.off('RoomMessage', handler)
resolve(msg.data as T)
}
}
this.on('RoomMessage', handler)
})
}
// ========================================================================
// Assertions | 断言辅助
// ========================================================================
/**
* @zh 是否收到过指定消息
* @en Whether received a specific message
*/
hasReceivedMessage(type: string): boolean {
return this._receivedMessages.some((m) => m.type === type)
}
/**
* @zh 获取指定类型的所有消息
* @en Get all messages of a specific type
*/
getMessagesOfType<T = unknown>(type: string): T[] {
return this._receivedMessages
.filter((m) => m.type === type)
.map((m) => m.data as T)
}
/**
* @zh 获取最后收到的指定类型消息
* @en Get the last received message of a specific type
*/
getLastMessage<T = unknown>(type: string): T | undefined {
for (let i = this._receivedMessages.length - 1; i >= 0; i--) {
if (this._receivedMessages[i].type === type) {
return this._receivedMessages[i].data as T
}
}
return undefined
}
/**
* @zh 清空消息记录
* @en Clear message records
*/
clearMessages(): void {
this._receivedMessages.length = 0
}
/**
* @zh 获取收到的消息数量
* @en Get received message count
*/
getMessageCount(type?: string): number {
if (type) {
return this._receivedMessages.filter((m) => m.type === type).length
}
return this._receivedMessages.length
}
// ========================================================================
// Private Methods | 私有方法
// ========================================================================
private _handleMessage(raw: Buffer): void {
try {
const packet = this._codec.decode(raw) as unknown[]
const type = packet[0] as number
switch (type) {
case PacketType.ApiResponse:
this._handleApiResponse([packet[0], packet[1], packet[2]] as [number, number, unknown])
break
case PacketType.ApiError:
this._handleApiError([packet[0], packet[1], packet[2], packet[3]] as [number, number, string, string])
break
case PacketType.Message:
this._handleMsg([packet[0], packet[1], packet[2]] as [number, string, unknown])
break
}
} catch (err) {
console.error('[TestClient] Failed to handle message:', err)
}
}
private _handleApiResponse([, id, result]: [number, number, unknown]): void {
const pending = this._pendingCalls.get(id)
if (pending) {
clearTimeout(pending.timer)
this._pendingCalls.delete(id)
pending.resolve(result)
}
}
private _handleApiError([, id, code, message]: [number, number, string, string]): void {
const pending = this._pendingCalls.get(id)
if (pending) {
clearTimeout(pending.timer)
this._pendingCalls.delete(id)
pending.reject(new Error(`[${code}] ${message}`))
}
}
private _handleMsg([, name, data]: [number, string, unknown]): void {
// 记录消息
this._receivedMessages.push({
type: name,
data,
timestamp: Date.now(),
})
// 触发处理器
const handlers = this._msgHandlers.get(name)
if (handlers) {
for (const handler of handlers) {
try {
handler(data)
} catch (err) {
console.error('[TestClient] Handler error:', err)
}
}
}
}
private _rejectAllPending(reason: string): void {
for (const [, pending] of this._pendingCalls) {
clearTimeout(pending.timer)
pending.reject(new Error(reason))
}
this._pendingCalls.clear()
}
}

View File

@@ -0,0 +1,249 @@
/**
* @zh 测试服务器工具
* @en Test server utilities
*/
import { createServer } from '../core/server.js'
import type { GameServer } from '../types/index.js'
import { TestClient, type TestClientOptions } from './TestClient.js'
// ============================================================================
// Types | 类型定义
// ============================================================================
/**
* @zh 测试服务器配置
* @en Test server options
*/
export interface TestServerOptions {
/**
* @zh 端口号0 表示随机端口
* @en Port number, 0 for random port
* @defaultValue 0
*/
port?: number
/**
* @zh Tick 速率
* @en Tick rate
* @defaultValue 0
*/
tickRate?: number
/**
* @zh 是否禁用控制台日志
* @en Whether to suppress console logs
* @defaultValue true
*/
silent?: boolean
}
/**
* @zh 测试环境
* @en Test environment
*/
export interface TestEnvironment {
/**
* @zh 服务器实例
* @en Server instance
*/
server: GameServer
/**
* @zh 服务器端口
* @en Server port
*/
port: number
/**
* @zh 创建测试客户端
* @en Create test client
*/
createClient(options?: TestClientOptions): Promise<TestClient>
/**
* @zh 创建多个测试客户端
* @en Create multiple test clients
*/
createClients(count: number, options?: TestClientOptions): Promise<TestClient[]>
/**
* @zh 清理测试环境
* @en Cleanup test environment
*/
cleanup(): Promise<void>
/**
* @zh 所有已创建的客户端
* @en All created clients
*/
readonly clients: ReadonlyArray<TestClient>
}
// ============================================================================
// Helper Functions | 辅助函数
// ============================================================================
/**
* @zh 获取随机可用端口
* @en Get a random available port
*/
async function getRandomPort(): Promise<number> {
const net = await import('node:net')
return new Promise((resolve, reject) => {
const server = net.createServer()
server.listen(0, () => {
const address = server.address()
if (address && typeof address === 'object') {
const port = address.port
server.close(() => resolve(port))
} else {
server.close(() => reject(new Error('Failed to get port')))
}
})
server.on('error', reject)
})
}
/**
* @zh 等待指定毫秒
* @en Wait for specified milliseconds
*/
export function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// ============================================================================
// Factory Functions | 工厂函数
// ============================================================================
/**
* @zh 创建测试服务器
* @en Create test server
*
* @example
* ```typescript
* const { server, port, cleanup } = await createTestServer()
* server.define('game', GameRoom)
*
* const client = new TestClient(port)
* await client.connect()
*
* // ... run tests ...
*
* await cleanup()
* ```
*/
export async function createTestServer(
options: TestServerOptions = {}
): Promise<{ server: GameServer; port: number; cleanup: () => Promise<void> }> {
const port = options.port || (await getRandomPort())
const silent = options.silent ?? true
// 临时禁用 console.log
const originalLog = console.log
if (silent) {
console.log = () => {}
}
const server = await createServer({
port,
tickRate: options.tickRate ?? 0,
apiDir: '__non_existent_api__',
msgDir: '__non_existent_msg__',
})
await server.start()
// 恢复 console.log
if (silent) {
console.log = originalLog
}
return {
server,
port,
cleanup: async () => {
await server.stop()
},
}
}
/**
* @zh 创建完整测试环境
* @en Create complete test environment
*
* @zh 包含服务器、客户端创建和清理功能的完整测试环境
* @en Complete test environment with server, client creation and cleanup
*
* @example
* ```typescript
* describe('GameRoom', () => {
* let env: TestEnvironment
*
* beforeEach(async () => {
* env = await createTestEnv()
* env.server.define('game', GameRoom)
* })
*
* afterEach(async () => {
* await env.cleanup()
* })
*
* it('should handle player join', async () => {
* const client = await env.createClient()
* const result = await client.joinRoom('game')
* expect(result.roomId).toBeDefined()
* })
*
* it('should broadcast to all players', async () => {
* const [client1, client2] = await env.createClients(2)
*
* await client1.joinRoom('game')
* const joinPromise = client1.waitForRoomMessage('PlayerJoined')
*
* await client2.joinRoom('game')
* const msg = await joinPromise
*
* expect(msg).toBeDefined()
* })
* })
* ```
*/
export async function createTestEnv(options: TestServerOptions = {}): Promise<TestEnvironment> {
const { server, port, cleanup: serverCleanup } = await createTestServer(options)
const clients: TestClient[] = []
return {
server,
port,
clients,
async createClient(clientOptions?: TestClientOptions): Promise<TestClient> {
const client = new TestClient(port, clientOptions)
await client.connect()
clients.push(client)
return client
},
async createClients(count: number, clientOptions?: TestClientOptions): Promise<TestClient[]> {
const newClients: TestClient[] = []
for (let i = 0; i < count; i++) {
const client = new TestClient(port, clientOptions)
await client.connect()
clients.push(client)
newClients.push(client)
}
return newClients
},
async cleanup(): Promise<void> {
// 断开所有客户端
await Promise.all(clients.map((c) => c.disconnect().catch(() => {})))
clients.length = 0
// 停止服务器
await serverCleanup()
},
}
}

View File

@@ -0,0 +1,37 @@
/**
* @zh 服务器测试工具
* @en Server testing utilities
*
* @example
* ```typescript
* import { createTestServer, TestClient } from '@esengine/server/testing'
*
* describe('GameRoom', () => {
* let env: TestEnvironment
*
* beforeEach(async () => {
* env = await createTestEnv()
* env.server.define('game', GameRoom)
* })
*
* afterEach(async () => {
* await env.cleanup()
* })
*
* it('should join room', async () => {
* const client = await env.createClient()
* const result = await client.joinRoom('game')
* expect(result.roomId).toBeDefined()
* })
* })
* ```
*/
export { TestClient, type TestClientOptions } from './TestClient.js'
export {
createTestServer,
createTestEnv,
type TestServerOptions,
type TestEnvironment,
} from './TestServer.js'
export { MockRoom } from './MockRoom.js'

View File

@@ -1,11 +1,11 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
entry: ['src/index.ts', 'src/testing/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
external: ['ws', '@esengine/rpc'],
external: ['ws', '@esengine/rpc', '@esengine/rpc/codec'],
treeshake: true,
})

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
testTimeout: 10000,
hookTimeout: 10000,
},
})