diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 345eb32..13b1d97 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -1,7 +1,7 @@ /** Shared UI primitives (docs/07 ยง4). */ import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { PublicUser } from '@bg/shared/lite'; -import { formatRoomCode } from '@bg/shared/lite'; +import { formatRoomCode, isBotId } from '@bg/shared/lite'; import { useToasts, toast } from '../store/toast'; import { useRoom } from '../net/socket'; import { haptic } from '../lib/feedback'; @@ -14,7 +14,7 @@ export function Avatar({ user, size = 40 }: { user: Pick - {user.avatar ? : [...user.nickname][0]} + {user.avatar ? : isBotId(user.id) ? '๐Ÿค–' : [...user.nickname][0]} ); } diff --git a/apps/web/src/net/api.ts b/apps/web/src/net/api.ts index cd1a690..9a54792 100644 --- a/apps/web/src/net/api.ts +++ b/apps/web/src/net/api.ts @@ -67,6 +67,8 @@ export const api = { deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: 'ํƒˆํ‡ด' }), stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'), createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }), + /** ์ปดํ“จํ„ฐ์™€ ์—ฐ์Šต: a private room with enough computer players to start (docs/06 ยง12). */ + practice: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId, practice: true }), quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}), room: (code: string) => call('GET', `/api/rooms/${code}`), allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'), diff --git a/apps/web/src/pages/GamePage.tsx b/apps/web/src/pages/GamePage.tsx index 91f5148..34fb6c5 100644 --- a/apps/web/src/pages/GamePage.tsx +++ b/apps/web/src/pages/GamePage.tsx @@ -1,7 +1,7 @@ /** ๊ฒŒ์ž„ ์†Œ๊ฐœ + ๋ฐฉ ๋งŒ๋“ค๊ธฐ / ๋น ๋ฅธ ์‹œ์ž‘ / ๊ทœ์น™ / ๊ณต๊ฐœ ๋ฐฉ ๋ชฉ๋ก (docs/06 ยง2, ยง7, ยง10). */ import { useEffect, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router'; -import { catalogById, formatRoomCode } from '@bg/shared/lite'; +import { botsAllowed, catalogById, formatRoomCode } from '@bg/shared/lite'; import { Sheet, TopBar } from '../components/ui'; import { NicknameForm } from '../components/NicknameForm'; import { api } from '../net/api'; @@ -64,6 +64,11 @@ export default function GamePage() { + {botsAllowed(g.id) && ( + + )} ๐Ÿ“– ๊ทœ์น™ ๋ณด๊ธฐ diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index ddcf1c7..aa2ba95 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -1,7 +1,7 @@ /** ํ™ˆ (PC ๊ธฐ์ค€, docs/07-ui-ux.md ยง3.3): ํฐ ํ–‰๋™ ์ค„ + ๊ฒŒ์ž„ ๊ฒฉ์ž + ์—ด๋ฆฐ ๊ณต๊ฐœ ๋ฐฉ. */ import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router'; -import { CATALOG, formatRoomCode } from '@bg/shared/lite'; +import { CATALOG, botsAllowed, formatRoomCode } from '@bg/shared/lite'; import { Sheet } from '../components/ui'; import { NicknameForm } from '../components/NicknameForm'; import { CodeInput } from '../components/CodeInput'; @@ -16,12 +16,13 @@ export function Home() { const { me, activeRoom } = useSession(); const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG)); const nav = useNavigate(); - const [picker, setPicker] = useState(false); + const [picker, setPicker] = useState(null); const [needName, setNeedName] = useState void)>(null); const [busy, setBusy] = useState(false); const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]); const [codeKey, setCodeKey] = useState(0); const available = catalog.filter((g) => g.available); + const practiceGames = available.filter((g) => botsAllowed(g.id)); useEffect(() => { const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {}); @@ -31,11 +32,11 @@ export function Home() { }, []); const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn)); - const create = (gameId: string) => + const create = (gameId: string, practice = false) => withName(async () => { setBusy(true); try { - const { code } = await api.createRoom(gameId); + const { code } = await (practice ? api.practice(gameId) : api.createRoom(gameId)); nav(`/r/${code}`); } catch (e) { toast((e as Error).message, 'error'); @@ -70,7 +71,7 @@ export function Home() { className="btn btn-primary btn-block" style={{ minHeight: 72, fontSize: '1.3rem' }} disabled={busy} - onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))} + onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker('room'))} > ๏ผ‹ ๋ฐฉ ๋งŒ๋“ค๊ธฐ @@ -80,6 +81,15 @@ export function Home() {

๋ฐ›์€ ์ˆซ์ž 6์ž๋ฆฌ๋ฅผ ๋„ฃ์œผ๋ฉด ๋ฐ”๋กœ ๋“ค์–ด๊ฐ€์š”.

+ {practiceGames.length > 0 && ( +
+

ํ˜ผ์ž ์—ฐ์Šตํ•˜๊ธฐ

+

์ปดํ“จํ„ฐ์™€ ๋ฐ”๋กœ ํ•œ ํŒ ํ•ด ๋ด์š”. ๊ธฐ๋ก์—๋Š” ๋‚จ์ง€ ์•Š์•„์š”.

+ +
+ )}
@@ -111,10 +121,10 @@ export function Home() {
{picker && ( - setPicker(false)} label="๊ฒŒ์ž„ ๊ณ ๋ฅด๊ธฐ"> -

์–ด๋–ค ๊ฒŒ์ž„์„ ํ• ๊นŒ์š”?

- {available.map((g) => ( - ))} diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index 5f4ac00..dbdcc82 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -1,7 +1,7 @@ /** ๋ฐฉ: ๋Œ€๊ธฐ์‹ค โ†” ๊ฒŒ์ž„ โ†” ๊ฒฐ๊ณผ๋ฅผ ํ•œ ์ฃผ์†Œ์—์„œ ์ „ํ™˜ (docs/06 ยง5โ€“ยง9). */ import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; -import { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite'; +import { botsAllowed, formatRoomCode, isBotId, type PublicUser, type RoomView } from '@bg/shared/lite'; import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui'; import { NicknameForm } from '../components/NicknameForm'; import { GameNotice, gameName } from '../components/GameNotice'; @@ -268,6 +268,11 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) const shareText = `${host}๋‹˜์ด ${gName} ๋ฐฉ์— ์ดˆ๋Œ€ํ–ˆ์–ด์š”! ์ฝ”๋“œ ${formatRoomCode(room.code)}`; const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length; const [askStart, setAskStart] = useState(false); + // Computer players (docs/06 ยง12): the host fills empty seats; a room with them is a practice room. + const canAddBot = isHost && botsAllowed(room.gameId); + const practice = room.seats.some((s) => s && isBotId(s.user.id)); + // Offered on the first empty seat only, so big rooms don't show a column of the same button. + const firstEmpty = room.seats.findIndex((s) => s === null); const wide = useWide(); const invite = ( @@ -293,7 +298,22 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
4 ? '1fr 1fr' : '1fr', gap: 10 }}> {room.seats.map((s, i) => - s ? ( + s && isBotId(s.user.id) ? ( +
+ +
+
{s.user.nickname}
+
+ ๐Ÿค– ์ปดํ“จํ„ฐ +
+
+ {isHost && ( + + )} +
+ ) : s ? (
@@ -311,9 +331,16 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) {isHost && s.user.id !== me.id && }
) : ( - +
+ + {canAddBot && i === firstEmpty && ( + + )} +
), )}
@@ -339,6 +366,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) )}

{room.visibility === 'public' ? '๊ณต๊ฐœ ๋ฐฉ (๋ˆ„๊ตฌ๋‚˜ ๋“ค์–ด์˜ฌ ์ˆ˜ ์žˆ์–ด์š”)' : '์นœ๊ตฌ๋งŒ (์ฝ”๋“œยท๋งํฌ๋กœ๋งŒ ๋“ค์–ด์™€์š”)'}

+ {practice &&

๐Ÿค– ์—ฐ์Šต ๊ฒŒ์ž„์ด์—์š”. ๊ธฐ๋ก์—๋Š” ๋‚จ์ง€ ์•Š์•„์š”.

} {room.lockedOptions.length > 0 &&

๐Ÿ”’ ์ผ๋ถ€ ๊ทœ์น™์€ ๊ด€๋ฆฌ์ž๊ฐ€ ์ •ํ•ด ๋‘์–ด์„œ ๋ฐ”๊ฟ€ ์ˆ˜ ์—†์–ด์š”.

} {isHost && wide && } @@ -478,7 +506,11 @@ function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI
๋ฐฉ ๊ณต๊ฐœ - {seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '๋ˆ„๊ตฌ๋‚˜', '์นœ๊ตฌ๋งŒ')} + {room.seats.some((s) => s && isBotId(s.user.id)) ? ( +

์ปดํ“จํ„ฐ๊ฐ€ ์žˆ๋Š” ๋ฐฉ์€ ์นœ๊ตฌ๋งŒ ๋“ค์–ด์™€์š”.

+ ) : ( + seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '๋ˆ„๊ตฌ๋‚˜', '์นœ๊ตฌ๋งŒ') + )}
๊ด€์ „ @@ -563,8 +595,10 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi const result = useRoom((s) => s.result)!; const [hidden, setHidden] = useState(false); const played = useRef(false); - const seated = room.seats.filter(Boolean).map((s) => s!.user.id); - const amSeated = seated.includes(me.id); + const seatedAll = room.seats.filter(Boolean).map((s) => s!.user.id); + const amSeated = seatedAll.includes(me.id); + // Computer players always agree to a rematch. + const seated = seatedAll.filter((u) => !isBotId(u)); const voted = room.rematchVotes.includes(me.id); // Solo games (e.g. ์š”ํŠธ ๋‹ค์ด์Šค) have one ranked player: neither a draw nor a win. const solo = result.ranking.flat().length === 1; @@ -592,6 +626,7 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi setHidden(true)} label="๊ฒŒ์ž„ ๊ฒฐ๊ณผ">

{allFirst ? '๋ฌด์Šน๋ถ€์˜ˆ์š”' : won ? '๐ŸŽ‰ ์ด๊ฒผ์–ด์š”!' : lost ? '์•„์‰ฝ๊ฒŒ ์กŒ์–ด์š”' : '๊ฒŒ์ž„์ด ๋๋‚ฌ์–ด์š”'}

{result.summary}

+ {seatedAll.length > seated.length &&

๐Ÿค– ์—ฐ์Šต ๊ฒŒ์ž„์ด๋ผ ๊ธฐ๋ก์—๋Š” ๋‚จ์ง€ ์•Š์•„์š”.

}
{result.ranking.map((group, i) => group.map((u) => ( diff --git a/e2e/bots.e2e.ts b/e2e/bots.e2e.ts new file mode 100644 index 0000000..66c0415 --- /dev/null +++ b/e2e/bots.e2e.ts @@ -0,0 +1,170 @@ +/** + * E2E (docs/06 ยง12): practice against computer players from the UI. + * 1) PC: ์˜ค๋ชฉ ์ƒ์„ธ โ†’ [์ปดํ“จํ„ฐ์™€ ์—ฐ์Šตํ•˜๊ธฐ] โ†’ ๋Œ€๊ธฐ์‹ค(์ปดํ“จํ„ฐ 1) โ†’ ์‹œ์ž‘ โ†’ ์ปดํ“จํ„ฐ๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ๋ฐ›์•„ ๋‘”๋‹ค. + * 2) ํœด๋Œ€ํฐ: ์›์นด๋“œ ์—ฐ์Šต โ†’ [์ปดํ“จํ„ฐ ๋„ฃ๊ธฐ]๋กœ 2๋ช…, ํ•˜๋‚˜ ๋” ๋„ฃ์—ˆ๋‹ค ๋นผ๊ธฐ โ†’ ์‹œ์ž‘ โ†’ ์‚ฌ๋žŒยท์ปดํ“จํ„ฐ๊ฐ€ ์—ฌ๋Ÿฌ ํ„ด. + * Run: E2E_PORT=4534 bun e2e/bots.e2e.ts (needs apps/web/dist built) + */ +import { chromium, type Page } from 'playwright-core'; +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadConfig } from '../apps/server/src/config'; +import { startServer } from '../apps/server/src/server'; + +const PORT = Number(process.env.E2E_PORT ?? 4534); +const ORIGIN = `http://localhost:${PORT}`; +const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results'); +mkdirSync(SHOTS, { recursive: true }); + +const dbDir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')); +const app = startServer({ + config: loadConfig({ PORT: String(PORT), DB_PATH: join(dbDir, 'app.db'), PUBLIC_ORIGIN: ORIGIN }), + log: () => {}, + staticDir: join(import.meta.dir, '../apps/web/dist'), +}); + +const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true }); +let failed = false; +const step = async (name: string, fn: () => Promise) => { + const t0 = performance.now(); + try { + await fn(); + console.log(`โœ“ ${name} (${Math.round(performance.now() - t0)}ms)`); + } catch (e) { + failed = true; + console.log(`โœ— ${name}: ${(e as Error).message.split('\n')[0]}`); + throw e; + } +}; + +const status = (p: Page) => p.locator('.turn-status').innerText().catch(() => ''); +const resultShown = async (p: Page) => (await p.getByRole('dialog', { name: '๊ฒŒ์ž„ ๊ฒฐ๊ณผ' }).count()) > 0; + +async function startPractice(p: Page, gameId: string, nick: string) { + await p.goto(`${ORIGIN}/g/${gameId}`); + await p.getByRole('button', { name: '๐Ÿค– ์ปดํ“จํ„ฐ์™€ ์—ฐ์Šตํ•˜๊ธฐ' }).click(); + await p.getByLabel('๋‚ด ์ด๋ฆ„(๋‹‰๋„ค์ž„)').fill(nick); + await p.getByRole('button', { name: '์ด ์ด๋ฆ„์œผ๋กœ ์‹œ์ž‘ํ•˜๊ธฐ' }).click(); + await p.waitForURL(/\/r\/\d{6}$/); + await p.getByLabel('์ž๋ฆฌ').getByText('์ปดํ“จํ„ฐ 1').waitFor(); + await p.getByText('์—ฐ์Šต ๊ฒŒ์ž„์ด์—์š”. ๊ธฐ๋ก์—๋Š” ๋‚จ์ง€ ์•Š์•„์š”.').first().waitFor(); +} + +// ---------------------------------------------------------------- omok (PC) +const PAD = 0.9; // board viewBox padding (coordinate labels) +async function stones(p: Page): Promise> { + const pts = await p.locator('svg[role="grid"] circle[r="0.46"]').evaluateAll((els) => + els.filter((e) => e.closest('g')?.getAttribute('opacity') !== '0.55').map((e) => [Number(e.getAttribute('cx')), Number(e.getAttribute('cy'))]), + ); + return new Set(pts.map(([x, y]) => y! * 15 + x!)); +} +async function clickCell(p: Page, x: number, y: number) { + const box = (await p.locator('svg[role="grid"]').boundingBox())!; + const span = 14 + 2 * PAD; + const px = box.x + ((x + PAD) / span) * box.width; + const py = box.y + ((y + PAD) / span) * box.height; + await p.mouse.move(px, py); + await p.mouse.click(px, py); +} + +try { + const pc = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' }); + const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' }); + const a = await pc.newPage(); + const b = await phone.newPage(); + const errors: string[] = []; + for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message)); + + await step('์˜ค๋ชฉ: ์ƒ์„ธ โ†’ ์ปดํ“จํ„ฐ์™€ ์—ฐ์Šตํ•˜๊ธฐ โ†’ ๋Œ€๊ธฐ์‹ค', async () => { + await startPractice(a, 'omok', 'ํ˜ธ๋ž‘์ด'); + await a.getByText('๐Ÿค– ์ปดํ“จํ„ฐ').first().waitFor(); + // The host is seated first, the computer next; no public toggle in a practice room. + await a.getByText('์ปดํ“จํ„ฐ๊ฐ€ ์žˆ๋Š” ๋ฐฉ์€ ์นœ๊ตฌ๋งŒ ๋“ค์–ด์™€์š”.').waitFor(); + await a.screenshot({ path: join(SHOTS, 'bots-1-lobby-pc.png') }); + }); + + await step('์˜ค๋ชฉ: ์‹œ์ž‘ โ†’ ์ปดํ“จํ„ฐ๊ฐ€ 5๋ฒˆ ๋ฐ›์•„ ๋‘”๋‹ค', async () => { + await a.getByRole('button', { name: 'โ–ถ ๊ฒŒ์ž„ ์‹œ์ž‘' }).click(); + await a.locator('svg[role="grid"]').waitFor(); + let answers = 0; + // Cells around the centre, nearest first. + const order = Array.from({ length: 225 }, (_, i) => i).sort((i, j) => Math.hypot((i % 15) - 7, Math.floor(i / 15) - 7) - Math.hypot((j % 15) - 7, Math.floor(j / 15) - 7)); + for (let turn = 0; turn < 8 && answers < 5; turn++) { + await a.waitForFunction(() => /๋‚ด ์ฐจ๋ก€์˜ˆ์š”|๊ฒŒ์ž„์ด ๋|์ด๊ฒผ|์กŒ/.test(document.querySelector('.turn-status')?.textContent ?? ''), null, { timeout: 8000 }); + if (await resultShown(a)) break; + const before = await stones(a); + const cell = order.find((i) => !before.has(i) && (i % 15) % 2 === turn % 2)!; + await clickCell(a, cell % 15, Math.floor(cell / 15)); + // Our stone, then the computer's answer (it thinks ~1 s). + await a.waitForFunction((n) => document.querySelectorAll('svg[role="grid"] g:not([opacity="0.55"]) > circle[r="0.46"]').length >= n + 2 || !!document.querySelector('[role="dialog"]'), before.size, { timeout: 8000 }); + if (await resultShown(a)) break; + answers++; + if (answers === 2) await a.screenshot({ path: join(SHOTS, 'bots-2-omok-pc.png') }); + } + if (answers < 3) throw new Error(`the computer answered only ${answers} times`); + console.log(` omok: computer answered ${answers} times`); + }); + + // ---------------------------------------------------------------- one-card (phone) + await step('์›์นด๋“œ: ์—ฐ์Šต ๋ฐฉ โ†’ ์ปดํ“จํ„ฐ ๋„ฃ๊ธฐยท๋นผ๊ธฐ โ†’ 3๋ช…', async () => { + await startPractice(b, 'one-card', '๊ณ ์–‘์ด'); + const seats = b.getByLabel('์ž๋ฆฌ'); + await seats.getByRole('button', { name: '๐Ÿค– ์ปดํ“จํ„ฐ ๋„ฃ๊ธฐ' }).click(); + await seats.getByText('์ปดํ“จํ„ฐ 2').waitFor(); + await seats.getByRole('button', { name: '๐Ÿค– ์ปดํ“จํ„ฐ ๋„ฃ๊ธฐ' }).click(); + await seats.getByText('์ปดํ“จํ„ฐ 3').waitFor(); + await seats.getByRole('button', { name: '์ปดํ“จํ„ฐ 3 ๋นผ๊ธฐ' }).click(); + await seats.getByText('์ปดํ“จํ„ฐ 3').waitFor({ state: 'detached' }); + await b.screenshot({ path: join(SHOTS, 'bots-3-lobby-phone.png'), fullPage: true }); + }); + + await step('์›์นด๋“œ: ์‹œ์ž‘ โ†’ ๋‚ด ์ฐจ๋ก€ 4๋ฒˆ, ์ปดํ“จํ„ฐ๋„ ๋‘”๋‹ค', async () => { + await b.getByRole('button', { name: 'โ–ถ ๊ฒŒ์ž„ ์‹œ์ž‘' }).click(); + await b.locator('.turn-status').waitFor(); + let mine = 0; + let botTurns = 0; + let last = ''; + for (let t = 0; t < 400 && mine < 4; t++) { + if (await resultShown(b)) break; + const one = b.locator('.oc-onebtn'); + if ((await one.count()) && (await one.isEnabled().catch(() => false))) await one.click({ timeout: 1000 }).catch(() => {}); + const s = await status(b); + if (s !== last && /์ปดํ“จํ„ฐ \d๋‹˜ ์ฐจ๋ก€์˜ˆ์š”/.test(s)) botTurns++; + last = s; + if (!s.startsWith('๋‚ด ์ฐจ๋ก€์˜ˆ์š”')) { + const next = b.getByRole('button', { name: /๋‹ค์Œ ํŒ/ }); + if (await next.count()) await next.first().click().catch(() => {}); + await b.waitForTimeout(150); + continue; + } + const accept = b.getByRole('button', { name: /^๋ฐ›์•„๋“ค์ด๊ธฐ/ }); + const card = b.locator('button.oc-card[data-legal="true"]').first(); + if (await accept.count()) await accept.click(); + else if (await card.count()) { + await card.click(); + await card.click(); + const pick = b.locator('.oc-pick button').first(); + if (await pick.waitFor({ timeout: 400 }).then(() => true, () => false)) await pick.click(); + } else { + const pass = b.getByRole('button', { name: '๋„˜๊ธฐ๊ธฐ' }); + if (await pass.count()) await pass.click(); + else await b.getByRole('button', { name: /์žฅ ๋จน๊ธฐ$/ }).first().click(); + } + await b.waitForFunction((x) => document.querySelector('.turn-status')?.textContent?.trim() !== x, s.trim(), { timeout: 8000 }); + mine++; + if (mine === 2) await b.screenshot({ path: join(SHOTS, 'bots-4-one-card-phone.png') }); + } + if (mine < 4 && !(await resultShown(b))) throw new Error(`only ${mine} of my turns were played`); + if (botTurns < 2) throw new Error(`computers took only ${botTurns} turns`); + console.log(` one-card: my turns ${mine}, computer turns seen ${botTurns}`); + }); + + if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`); +} catch (e) { + failed = true; + console.error(e); +} finally { + await browser.close(); + await app.stop(); +} +console.log(failed ? 'E2E FAILED' : 'E2E PASSED'); +process.exit(failed ? 1 : 0);