- 在游戏状态中添加 needDraw 字段用于标识当前回合是否需要摸牌 - 实现 canDrawTile 计算属性控制摸牌按钮的显示和启用状态 - 添加 claimActionPending 状态防止重复提交操作 - 实现 myClaimState、visibleClaimOptions 和 showClaimActions 计算属性 - 添加 submitClaim 方法处理碰/杠/胡/过操作 - 实现 normalizePendingClaim 函数解析服务端推送的声明状态 - 在底部手牌区域将牌图片改为按钮以便点击弃牌 - 添加摸牌按钮和声明操作栏界面元素 - 更新房间创建表单添加局数选择选项 - 添加 E2E 测试文件验证多人房间流程 - 为登录页面输入框和按钮添加 testid 属性便于测试 - 修复 test-results 文件中的失败测试记录
293 lines
9.8 KiB
TypeScript
293 lines
9.8 KiB
TypeScript
import { expect, request, test, type Browser, type BrowserContext, type Page } from 'playwright/test'
|
|
|
|
const liveEnabled = process.env.PLAYWRIGHT_LIVE === '1'
|
|
const apiBaseURL = process.env.E2E_LIVE_API_BASE_URL ?? 'http://127.0.0.1:19000'
|
|
const password = process.env.E2E_LIVE_PASSWORD ?? 'Passw0rd!'
|
|
const configuredUsers = (process.env.E2E_LIVE_USERS ?? '')
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
|
|
type PlayerPage = {
|
|
context: BrowserContext
|
|
page: Page
|
|
username: string
|
|
password: string
|
|
}
|
|
|
|
test.skip(!liveEnabled, 'set PLAYWRIGHT_LIVE=1 to run live integration flow')
|
|
|
|
test('live room flow: create, join, ready, start, ding que, multi-turn discard', async ({ browser }) => {
|
|
test.setTimeout(180_000)
|
|
|
|
const api = await request.newContext({
|
|
baseURL: apiBaseURL,
|
|
extraHTTPHeaders: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
})
|
|
|
|
const players: PlayerPage[] = []
|
|
|
|
try {
|
|
await expectLiveStackReady(api)
|
|
|
|
if (configuredUsers.length > 0) {
|
|
for (const username of configuredUsers) {
|
|
players.push(
|
|
await openPlayerPage(browser, {
|
|
username,
|
|
password,
|
|
}),
|
|
)
|
|
}
|
|
} else {
|
|
const sessions = await Promise.all(
|
|
Array.from({ length: 4 }, (_, index) => createLiveUserSession(api, index)),
|
|
)
|
|
for (const session of sessions) {
|
|
players.push(await openPlayerPage(browser, session))
|
|
}
|
|
}
|
|
|
|
const [owner, guest2, guest3, guest4] = players
|
|
|
|
await owner.page.goto('/hall')
|
|
await expect(owner.page.getByTestId('open-create-room')).toBeVisible()
|
|
await owner.page.getByTestId('open-create-room').click()
|
|
await owner.page.getByTestId('create-room-name').fill(`live-room-${Date.now()}`)
|
|
const createRoomResponsePromise = owner.page.waitForResponse((response) => {
|
|
return response.url().includes('/api/v1/game/mahjong/room/create') && response.request().method() === 'POST'
|
|
})
|
|
await owner.page.getByTestId('submit-create-room').click()
|
|
|
|
const createRoomResponse = await createRoomResponsePromise
|
|
if (!createRoomResponse.ok()) {
|
|
throw new Error(
|
|
`create room failed: status=${createRoomResponse.status()} body=${await createRoomResponse.text()} request=${createRoomResponse.request().postData() ?? ''}`,
|
|
)
|
|
}
|
|
const createRoomPayload = (await createRoomResponse.json()) as { data?: { room_id?: string; name?: string } }
|
|
const roomID = createRoomPayload.data?.room_id
|
|
if (!roomID) {
|
|
throw new Error('live room id not found after creating room')
|
|
}
|
|
const roomName = createRoomPayload.data?.name ?? ''
|
|
|
|
const enterCreatedRoomButton = owner.page.getByTestId('enter-created-room')
|
|
if (await enterCreatedRoomButton.isVisible().catch(() => false)) {
|
|
await enterCreatedRoomButton.click()
|
|
} else {
|
|
await owner.page.goto(`/game/chengdu/${roomID}${roomName ? `?roomName=${encodeURIComponent(roomName)}` : ''}`)
|
|
}
|
|
|
|
await expect(owner.page).toHaveURL(new RegExp(`/game/chengdu/${roomID}`))
|
|
|
|
for (const guest of [guest2, guest3, guest4]) {
|
|
await guest.page.goto('/hall')
|
|
await expect(guest.page.getByTestId('quick-join-room-id')).toBeVisible()
|
|
await guest.page.getByTestId('quick-join-room-id').fill(roomID)
|
|
await guest.page.getByTestId('quick-join-submit').click()
|
|
await expect(guest.page).toHaveURL(new RegExp(`/game/chengdu/${roomID}(\\?.*)?$`))
|
|
}
|
|
|
|
for (const player of players) {
|
|
await expect(player.page.getByTestId('ready-toggle')).toBeVisible()
|
|
await player.page.getByTestId('ready-toggle').click()
|
|
}
|
|
|
|
await expect(owner.page.getByTestId('start-game')).toBeEnabled({ timeout: 20_000 })
|
|
await owner.page.getByTestId('start-game').click()
|
|
|
|
const dingQueChoices: Array<'w' | 't' | 'b'> = ['w', 't', 'b', 'w']
|
|
for (const [index, player] of players.entries()) {
|
|
const suit = dingQueChoices[index]
|
|
const dingQueButton = player.page.getByTestId(`ding-que-${suit}`)
|
|
await expect(dingQueButton).toBeVisible({ timeout: 20_000 })
|
|
await expect(dingQueButton).toBeEnabled()
|
|
await dingQueButton.click()
|
|
}
|
|
|
|
const discardActors: string[] = []
|
|
let previousActor: PlayerPage | null = null
|
|
|
|
for (let turn = 0; turn < 4; turn += 1) {
|
|
const actor = await findDiscardActor(players, 30_000, previousActor)
|
|
const actorTiles = actor.page.locator('[data-testid^="hand-tile-"]')
|
|
expect(await actorTiles.count()).toBeGreaterThan(0)
|
|
expect(await countEnabledTiles(actor.page)).toBeGreaterThan(0)
|
|
|
|
await actorTiles.first().click()
|
|
await expect
|
|
.poll(() => countEnabledTiles(actor.page), { timeout: 20_000 })
|
|
.toBe(0)
|
|
|
|
await resolvePendingClaims(players, 10_000)
|
|
await drawIfNeeded(players, 10_000)
|
|
|
|
discardActors.push(actor.username)
|
|
previousActor = actor
|
|
}
|
|
|
|
expect(new Set(discardActors).size).toBeGreaterThan(1)
|
|
} finally {
|
|
await api.dispose()
|
|
await Promise.all(players.map(async ({ context }) => context.close()))
|
|
}
|
|
})
|
|
|
|
async function expectLiveStackReady(api: Awaited<ReturnType<typeof request.newContext>>): Promise<void> {
|
|
const response = await api.get('/healthz')
|
|
expect(response.ok()).toBeTruthy()
|
|
}
|
|
|
|
async function createLiveUserSession(
|
|
api: Awaited<ReturnType<typeof request.newContext>>,
|
|
index: number,
|
|
): Promise<{ username: string; password: string }> {
|
|
const seed = `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`
|
|
const username = `pwlive_${seed}`
|
|
const timestampDigits = String(Date.now()).slice(-8)
|
|
const randomDigit = Math.floor(Math.random() * 10)
|
|
const phone = `13${timestampDigits}${index}${randomDigit}`
|
|
const email = `${username}@example.com`
|
|
|
|
const registerResponse = await api.post('/api/v1/auth/register', {
|
|
data: {
|
|
username,
|
|
phone,
|
|
email,
|
|
password,
|
|
},
|
|
})
|
|
expect(registerResponse.ok(), await registerResponse.text()).toBeTruthy()
|
|
|
|
const loginResponse = await api.post('/api/v1/auth/login', {
|
|
data: {
|
|
login_id: username,
|
|
password,
|
|
},
|
|
})
|
|
expect(loginResponse.ok(), await loginResponse.text()).toBeTruthy()
|
|
|
|
return {
|
|
username,
|
|
password,
|
|
}
|
|
}
|
|
|
|
async function openPlayerPage(
|
|
browser: Browser,
|
|
session: { username: string; password: string },
|
|
): Promise<PlayerPage> {
|
|
const context = await browser.newContext()
|
|
const page = await context.newPage()
|
|
await page.goto('/login')
|
|
await page.getByTestId('login-id').fill(session.username)
|
|
await page.getByTestId('login-password').fill(session.password)
|
|
const loginResponsePromise = page.waitForResponse((response) => {
|
|
return response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST'
|
|
})
|
|
await page.getByTestId('login-submit').click()
|
|
const loginResponse = await loginResponsePromise
|
|
if (!loginResponse.ok()) {
|
|
throw new Error(
|
|
`browser login failed: status=${loginResponse.status()} body=${await loginResponse.text()} request=${loginResponse.request().postData() ?? ''}`,
|
|
)
|
|
}
|
|
await expect(page).toHaveURL(/\/hall$/)
|
|
return {
|
|
context,
|
|
page,
|
|
username: session.username,
|
|
password: session.password,
|
|
}
|
|
}
|
|
|
|
async function findDiscardActor(
|
|
players: PlayerPage[],
|
|
timeoutMs: number,
|
|
previousActor?: PlayerPage | null,
|
|
): Promise<PlayerPage> {
|
|
const deadline = Date.now() + timeoutMs
|
|
|
|
while (Date.now() < deadline) {
|
|
for (const player of players) {
|
|
if (previousActor && player.username === previousActor.username) {
|
|
continue
|
|
}
|
|
const firstTile = player.page.locator('[data-testid^="hand-tile-"]').first()
|
|
const tileCount = await player.page.locator('[data-testid^="hand-tile-"]').count()
|
|
if (tileCount === 0) {
|
|
continue
|
|
}
|
|
if (await firstTile.isEnabled().catch(() => false)) {
|
|
return player
|
|
}
|
|
}
|
|
await players[0]?.page.waitForTimeout(250)
|
|
}
|
|
|
|
const diagnostics = await Promise.all(
|
|
players.map(async (player) => {
|
|
const logs = await player.page.locator('.sidebar-line').allTextContents().catch(() => [])
|
|
const claimBarVisible = await player.page.getByTestId('claim-action-bar').isVisible().catch(() => false)
|
|
const passVisible = await player.page.getByTestId('claim-pass').isVisible().catch(() => false)
|
|
const enabledTiles = await countEnabledTiles(player.page)
|
|
return [
|
|
`player=${player.username}`,
|
|
`enabledTiles=${enabledTiles}`,
|
|
`claimBar=${claimBarVisible}`,
|
|
`claimPass=${passVisible}`,
|
|
`logs=${logs.slice(0, 4).join(' || ')}`,
|
|
].join(' ')
|
|
}),
|
|
)
|
|
|
|
throw new Error(`no player reached enabled discard state within timeout\n${diagnostics.join('\n')}`)
|
|
}
|
|
|
|
async function countEnabledTiles(page: Page): Promise<number> {
|
|
return page.locator('[data-testid^="hand-tile-"]:enabled').count()
|
|
}
|
|
|
|
async function resolvePendingClaims(players: PlayerPage[], timeoutMs: number): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs
|
|
|
|
while (Date.now() < deadline) {
|
|
let handled = false
|
|
|
|
for (const player of players) {
|
|
const passButton = player.page.getByTestId('claim-pass')
|
|
if (await passButton.isVisible().catch(() => false)) {
|
|
await expect(passButton).toBeEnabled({ timeout: 5_000 })
|
|
await passButton.click()
|
|
handled = true
|
|
}
|
|
}
|
|
|
|
if (!handled) {
|
|
return
|
|
}
|
|
|
|
await players[0]?.page.waitForTimeout(300)
|
|
}
|
|
}
|
|
|
|
async function drawIfNeeded(players: PlayerPage[], timeoutMs: number): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs
|
|
|
|
while (Date.now() < deadline) {
|
|
for (const player of players) {
|
|
const drawButton = player.page.getByTestId('draw-tile')
|
|
if (await drawButton.isVisible().catch(() => false)) {
|
|
await expect(drawButton).toBeEnabled({ timeout: 5_000 })
|
|
await drawButton.click()
|
|
return
|
|
}
|
|
}
|
|
|
|
await players[0]?.page.waitForTimeout(250)
|
|
}
|
|
}
|