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:
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
134
packages/framework/server/src/testing/MockRoom.ts
Normal file
134
packages/framework/server/src/testing/MockRoom.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
371
packages/framework/server/src/testing/Room.test.ts
Normal file
371
packages/framework/server/src/testing/Room.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
523
packages/framework/server/src/testing/TestClient.ts
Normal file
523
packages/framework/server/src/testing/TestClient.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
249
packages/framework/server/src/testing/TestServer.ts
Normal file
249
packages/framework/server/src/testing/TestServer.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
37
packages/framework/server/src/testing/index.ts
Normal file
37
packages/framework/server/src/testing/index.ts
Normal 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'
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
11
packages/framework/server/vitest.config.ts
Normal file
11
packages/framework/server/vitest.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user