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>): Promise { const response = await api.get('/healthz') expect(response.ok()).toBeTruthy() } async function createLiveUserSession( api: Awaited>, 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 { 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 { 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 { return page.locator('[data-testid^="hand-tile-"]:enabled').count() } async function resolvePendingClaims(players: PlayerPage[], timeoutMs: number): Promise { 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 { 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) } }