From 32e3e89d8d07c683ff3be66e73343d6317a7d13f Mon Sep 17 00:00:00 2001 From: EJClaw Date: Tue, 6 Oct 2026 03:10:14 +0900 Subject: [PATCH] =?UTF-8?q?M9:=20[=3F]=20=EB=8F=84=EC=9B=80=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=EC=9D=84=20=EC=B0=A8=EB=A1=80=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EC=98=86=EC=9C=BC=EB=A1=9C,=20=ED=8A=9C=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EC=96=BC=20E2E,=20=EA=B8=B0=EC=A1=B4=20E2E=EB=8A=94=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EC=B9=B4=EB=93=9C=20=EB=B3=B8=20=EA=B2=83?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/GameHelp.tsx | 7 +- apps/web/src/pages/RoomPage.tsx | 12 ++- e2e/a11y.e2e.ts | 2 +- e2e/games.e2e.ts | 2 + e2e/omok.e2e.ts | 2 + e2e/tutorial.e2e.ts | 147 +++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 e2e/tutorial.e2e.ts diff --git a/apps/web/src/components/GameHelp.tsx b/apps/web/src/components/GameHelp.tsx index f8d09ee..a12be60 100644 --- a/apps/web/src/components/GameHelp.tsx +++ b/apps/web/src/components/GameHelp.tsx @@ -87,7 +87,7 @@ export function QuickStartButton({ title, cards }: { title: string; cards: Quick } /** - * [?] button for the room bar while a game is on screen. Also opens the summary cards by itself + * [?] button next to the turn status while a game is on screen. Also opens the summary cards by itself * the first time this device sees a running game of this kind. */ export function GameHelp({ room, ui, title, me, players }: { room: RoomView; ui: GameUI; title: string; me: string; players: Record }) { @@ -118,8 +118,9 @@ export function GameHelp({ room, ui, title, me, players }: { room: RoomView; ui: return ( <> - {open && createPortal( diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index 3c1bf34..c7d3334 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -160,7 +160,6 @@ function Room({ code, me }: { code: string; me: PublicUser }) { 방 🔗} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" /> - {inGame && game && } {!roomChatClosed && ( setChatOpen(true)} /> @@ -546,14 +545,21 @@ function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui )} ); + // [?] 지금 할 수 있는 것 sits right next to the turn status (docs/07 §5). + const statusRow = ( +
+ {status} + +
+ ); return (
- {!wide && status} + {!wide && statusRow}
diff --git a/e2e/a11y.e2e.ts b/e2e/a11y.e2e.ts index 3dd6a51..39a87db 100644 --- a/e2e/a11y.e2e.ts +++ b/e2e/a11y.e2e.ts @@ -8,7 +8,7 @@ import { join } from 'node:path'; import { loadConfig } from '../apps/server/src/config'; import { startServer } from '../apps/server/src/server'; -const PORT = 4520; +const PORT = Number(process.env.E2E_PORT ?? 4520); const ORIGIN = `http://localhost:${PORT}`; const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8'); const app = startServer({ diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 81cf3f4..1258544 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -2014,6 +2014,8 @@ for (const id of games) { } const guest = guests[0]!; for (const p of [host, ...guests]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`)); + // The first-game "30초 규칙 요약" cards are tested in e2e/tutorial.e2e.ts; mark them seen here. + for (const p of [host, ...guests]) await p.addInitScript((g) => localStorage.setItem(`bg:qs:${g}`, '1'), id); try { await host.goto(`${ORIGIN}/g/${id}`); await host.getByRole('button', { name: /친구랑 하기/ }).click(); diff --git a/e2e/omok.e2e.ts b/e2e/omok.e2e.ts index 55bad1c..bc50fbd 100644 --- a/e2e/omok.e2e.ts +++ b/e2e/omok.e2e.ts @@ -61,6 +61,8 @@ try { const b = await ctxB.newPage(); const errors: string[] = []; for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message)); + // The first-game "30초 규칙 요약" cards are tested in e2e/tutorial.e2e.ts. + for (const p of [a, b]) await p.addInitScript(() => localStorage.setItem('bg:qs:omok', '1')); let code = ''; await step('A: 홈 → 방 만들기 → 닉네임 → 대기실', async () => { diff --git a/e2e/tutorial.e2e.ts b/e2e/tutorial.e2e.ts new file mode 100644 index 0000000..9bf5eff --- /dev/null +++ b/e2e/tutorial.e2e.ts @@ -0,0 +1,147 @@ +/** + * E2E (docs/07 §5 초보자 도움): the first game of a kind on a device shows the 3 "30초 규칙 요약" cards + * ([다음]/[건너뛰기]/[시작하기]), the next game doesn't, and the in-game [?] sheet explains the + * situation and opens the full rules and the cards again. Also runs axe on both sheets. + * Run: bun e2e/tutorial.e2e.ts (needs apps/web/dist built) + */ +import { chromium, type Page } from 'playwright-core'; +import { mkdirSync, mkdtempSync, readFileSync } 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 ?? 4521); +const ORIGIN = `http://localhost:${PORT}`; +const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results'); +mkdirSync(SHOTS, { recursive: true }); +const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8'); + +const app = startServer({ + config: loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')), '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; + } +}; + +/** axe on the open sheet only (the game board under it is covered by e2e/a11y.e2e.ts). */ +async function axe(page: Page, name: string) { + await page.waitForTimeout(400); // sheet fade-in would lower measured contrast + await page.addScriptTag({ content: AXE }); + const res = await page.evaluate(async () => (window as any).axe.run('.sheet', { resultTypes: ['violations'] })); + const serious = res.violations.filter((v: any) => v.impact === 'serious' || v.impact === 'critical'); + if (serious.length) throw new Error(`axe ${name}: ${serious.map((v: any) => `${v.id} ${v.nodes[0]?.target?.join(' ')}`).join('; ')}`); +} + +const cards = (p: Page) => p.getByRole('dialog', { name: '오목 30초 규칙 요약' }); +const help = (p: Page) => p.getByRole('dialog', { name: '지금 할 수 있는 것' }); + +try { + const a = await (await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' })).newPage(); + const b = await (await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' })).newPage(); + const errors: string[] = []; + for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message)); + + await step('방 만들기 → 친구 입장 → 시작', async () => { + await a.goto(`${ORIGIN}/g/omok`); + await a.getByRole('button', { name: /친구랑 하기/ }).click(); + await a.getByLabel('내 이름(닉네임)').fill('호랑이'); + await a.getByRole('button', { name: '이 이름으로 시작하기' }).click(); + await a.waitForURL(/\/r\/\d{6}$/); + if (await a.getByRole('button', { name: /도움/ }).count()) throw new Error('[?] shown in the lobby'); + await b.goto(a.url()); + await b.getByLabel('내 이름(닉네임)').fill('고양이'); + await b.getByRole('button', { name: '이 이름으로 입장하기' }).click(); + await a.getByLabel('자리').getByText('고양이').waitFor(); + await a.getByRole('button', { name: '▶ 게임 시작' }).click(); + await a.getByRole('dialog', { name: '준비 안 된 사람이 있어요' }).getByRole('button', { name: '시작하기' }).click(); + }); + + await step('처음 하는 게임: 요약 카드 3장 (PC는 끝까지, 휴대폰은 건너뛰기)', async () => { + await cards(a).getByText('1/3').waitFor(); + await cards(b).getByText('1/3').waitFor(); + await b.waitForTimeout(400); // sheet animation + await b.screenshot({ path: join(SHOTS, 'tutorial-1-cards-phone.png') }); + await axe(a, '요약 카드'); + await cards(a).getByRole('button', { name: '다음 →' }).click(); + await cards(a).getByText('2/3').waitFor(); + await cards(a).getByRole('button', { name: '다음 →' }).click(); + await cards(a).getByText('3/3').waitFor(); + if (await cards(a).getByRole('button', { name: '건너뛰기' }).count()) throw new Error('last card still offers 건너뛰기'); + await a.screenshot({ path: join(SHOTS, 'tutorial-2-cards-last-pc.png') }); + await cards(a).getByRole('button', { name: '시작하기' }).click(); + await cards(a).waitFor({ state: 'detached' }); + await cards(b).getByRole('button', { name: '건너뛰기' }).click(); + await cards(b).waitFor({ state: 'detached' }); + for (const p of [a, b]) { + if ((await p.evaluate(() => localStorage.getItem('bg:qs:omok'))) !== '1') throw new Error('seen flag not stored'); + } + }); + + await step('[?] → 지금 할 수 있는 것 + 전체 규칙 + 요약 다시 보기', async () => { + await a.getByRole('button', { name: '도움말: 지금 할 수 있는 것' }).click(); + const text = await help(a).locator('.help-now').innerText(); + if (!/차례예요/.test(text)) throw new Error(`no situation sentence: ${text}`); + await axe(a, '도움말'); + await help(a).getByRole('button', { name: /전체 규칙 보기/ }).click(); + await help(a).getByText('5개를 먼저 이으면 이겨요', { exact: false }).first().waitFor(); + await a.screenshot({ path: join(SHOTS, 'tutorial-3-help-pc.png') }); + await help(a).getByRole('button', { name: /요약 다시 보기/ }).click(); + await cards(a).getByText('1/3').waitFor(); + await cards(a).getByRole('button', { name: '건너뛰기' }).click(); + await cards(a).waitFor({ state: 'detached' }); + // Phone: the [?] next to the turn status doesn't widen the page. + const sw = await b.evaluate(() => document.documentElement.scrollWidth); + if (sw > 390) throw new Error(`phone page is ${sw}px wide`); + await b.getByRole('button', { name: '도움말: 지금 할 수 있는 것' }).click(); + const phone = await help(b).locator('.help-now').innerText(); + if (!/차례예요/.test(phone)) throw new Error(`no situation sentence on phone: ${phone}`); + await b.waitForTimeout(400); + await b.screenshot({ path: join(SHOTS, 'tutorial-4-help-phone.png') }); + await help(b).getByRole('button', { name: '닫기' }).click(); + await help(b).waitFor({ state: 'detached' }); + }); + + await step('두 번째 판: 요약 카드가 다시 나오지 않아요', async () => { + await a.getByRole('button', { name: /기권/ }).click(); + await a.getByRole('button', { name: '기권하기' }).click(); + await a.getByRole('button', { name: /한 판 더/ }).click(); + await b.getByRole('button', { name: /한 판 더/ }).click(); + await a.getByText(/차례예요|생각 중이에요/).first().waitFor(); + await a.waitForTimeout(800); + if ((await cards(a).count()) + (await cards(b).count())) throw new Error('cards shown again'); + await a.reload(); + await a.locator('.turn-status').waitFor(); + await a.waitForTimeout(500); + if (await cards(a).count()) throw new Error('cards shown after reload'); + }); + + await step('규칙 페이지: 30초 요약 보기', async () => { + await a.goto(`${ORIGIN}/rules/omok`); + await a.getByRole('button', { name: /30초 요약 보기/ }).click(); + await cards(a).getByText('1/3').waitFor(); + await cards(a).getByRole('button', { name: '건너뛰기' }).click(); + await cards(a).waitFor({ state: 'detached' }); + }); + + 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);