Files
mahjong-web/tests/e2e/room-flow.live.spec.ts
wsy182 7751d3b8e3 feat(game): 添加摸牌和碰杠胡操作功能
- 在游戏状态中添加 needDraw 字段用于标识当前回合是否需要摸牌
- 实现 canDrawTile 计算属性控制摸牌按钮的显示和启用状态
- 添加 claimActionPending 状态防止重复提交操作
- 实现 myClaimState、visibleClaimOptions 和 showClaimActions 计算属性
- 添加 submitClaim 方法处理碰/杠/胡/过操作
- 实现 normalizePendingClaim 函数解析服务端推送的声明状态
- 在底部手牌区域将牌图片改为按钮以便点击弃牌
- 添加摸牌按钮和声明操作栏界面元素
- 更新房间创建表单添加局数选择选项
- 添加 E2E 测试文件验证多人房间流程
- 为登录页面输入框和按钮添加 testid 属性便于测试
- 修复 test-results 文件中的失败测试记录
2026-03-29 17:46:34 +08:00

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