import { expect, test, type Page, type Route } from '@playwright/test'; import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const imageRoots = [ ...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT)] : []), resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image'), ]; const artifactRoot = process.env.BOARD_ARTIFACT_DIR; type Article = { id: number; title: string; content: string; authorName: string; authorPicture: string | null; authorImageServer: number; createdAt: string; comments: Array<{ id: number; authorName: string; content: string; createdAt: string; }>; }; type BoardFixture = { permission: number; canMeeting: boolean; canSecret: boolean; articles: Article[]; failArticle?: boolean; failComment?: boolean; requests: string[]; }; const response = (data: unknown) => ({ result: { data } }); const errorResponse = (path: string, message: string) => ({ error: { message, code: -32000, data: { code: 'BAD_REQUEST', httpStatus: 400, path }, }, }); const operationNames = (route: Route): string[] => { const url = new URL(route.request().url()); return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); }; const initialArticles = (): Article[] => [ { id: 11, title: '첫 작전 회의', content: '낙양을 지킵시다.\n병력은 서문에 배치합니다.', authorName: '유비', authorPicture: '22.jpg', authorImageServer: 0, createdAt: '2026-07-26T10:20:00.000Z', comments: [ { id: 21, authorName: '관우', content: '확인했습니다.', createdAt: '2026-07-26T10:25:00.000Z', }, ], }, ]; const readImage = async (relative: string): Promise => { for (const root of imageRoots) { try { return await readFile(resolve(root, relative)); } catch { // Main checkout and feature worktrees have different image-root parents. } } throw new Error(`Reference image not found: ${relative}`); }; const installImages = async (page: Page) => { for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { await page.route(`https://sam-image.hided.net/game/${filename}`, async (route) => { await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readImage(`game/${filename}`), }); }); } await page.route('https://sam-image.hided.net/icons/**', async (route) => { await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readImage('icons/22.jpg'), }); }); }; const generalContext = { general: { id: 1, name: '유비', npcState: 0, nationId: 1, cityId: 1, troopId: 0, picture: '22.jpg', imageServer: 0, officerLevel: 1, stats: { leadership: 80, strength: 70, intelligence: 75 }, gold: 1000, rice: 1000, crew: 500, train: 100, atmos: 100, injury: 0, experience: 100, dedication: 100, items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, }, city: null, nation: null, settings: {}, penalties: {}, }; const emptyMessages = { private: [], national: [], public: [], diplomacy: [], sequence: -1, hasMore: { private: false, national: false, public: false, diplomacy: false }, latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 }, canRespondDiplomacy: false, }; const installApi = async (page: Page, state: BoardFixture) => { await installImages(page); await page.addInitScript((profile) => { window.localStorage.setItem('sammo-game-token', 'ga_board_playwright'); window.localStorage.setItem('sammo-game-profile', profile); }, gameProfile); await page.route(gameTrpcRoute, async (route) => { const operations = operationNames(route); const results = operations.map((operation) => { state.requests.push(operation); if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') { return response({ year: 197, month: 7, userCnt: 2, maxUserCnt: 50, npcCnt: 0, nationCnt: 1, turnTerm: 60, fictionMode: '가상', starttime: '', opentime: '', turntime: '', otherTextInfo: '', isUnited: 0, myGeneral: { name: '유비', picture: '22.jpg' }, }); } if (operation === 'join.getConfig') return response({}); if (operation === 'board.getAccess') { return response({ permission: state.permission, canMeeting: state.canMeeting, canSecret: state.canSecret, }); } if (operation === 'board.getArticles') return response(state.articles); if (operation === 'board.writeArticle') { if (state.failArticle) { state.failArticle = false; return errorResponse(operation, '접속 제한입니다.'); } state.articles.unshift({ id: 12, title: '새 제목', content: '새 내용', authorName: '유비', authorPicture: '22.jpg', authorImageServer: 0, createdAt: '2026-07-26T11:00:00.000Z', comments: [], }); return response({ id: 12 }); } if (operation === 'board.writeComment') { if (state.failComment) { state.failComment = false; return errorResponse(operation, '접속 제한입니다.'); } state.articles[0]?.comments.push({ id: 22, authorName: '유비', content: '새 댓글', createdAt: '2026-07-26T11:05:00.000Z', }); return response({ id: 22 }); } if (operation === 'general.me') return response(generalContext); if (operation === 'world.getMapLayout') { return response({ mapName: 'che', cityList: [], regionMap: {}, levelMap: {} }); } if (operation === 'world.getMap') { return response({ year: 197, month: 7, startYear: 190, cityList: [], nationList: [], myCity: 1, myNation: 1, }); } if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'messages.getRecent') return response(emptyMessages); if (operation === 'messages.getContacts') return response({ nation: [] }); if (operation === 'turns.reserved.getGeneral') return response({ turns: [], revision: 0 }); if (operation === 'turns.reserved.getNation') return response({ turns: [], revision: 0 }); if (operation === 'general.getRecentRecords') return response({ global: [], general: [], history: [] }); if (operation === 'general.getFrontStatus') return response({ onlineUserCount: 1, onlineNations: '아국(1)', onlineGenerals: '유비', nationNotice: '', lastExecuted: null, latestVote: null, }); if (operation === 'tournament.getState') return response({ stage: 0 }); return errorResponse(operation, `Unhandled board fixture operation: ${operation}`); }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results), }); }); }; const gotoBoard = async (page: Page, path = 'board') => { const articlesResponse = page.waitForResponse( (result) => result.url().includes('/trpc/board.getArticles') && result.ok() ); await page.goto(path); await articlesResponse; await expect(page.locator('.article-frame')).toBeVisible(); }; test('matches the ref meeting-room geometry, typography, textures, and controls', async ({ page }) => { const state: BoardFixture = { permission: 0, canMeeting: true, canSecret: false, articles: initialArticles(), requests: [], }; await installApi(page, state); await page.setViewportSize({ width: 1000, height: 800 }); await gotoBoard(page); await expect(page.locator('.general-icon').first()).toHaveAttribute( 'src', 'https://sam-image.hided.net/icons/22.jpg' ); await expect(page.locator('.article-header .date')).toHaveText('07-26 19:20'); await expect(page.locator('.comment-row .date')).toHaveText('07-26 19:25'); if (artifactRoot) { await page.screenshot({ path: resolve(artifactRoot, 'board-core-desktop.png'), fullPage: true, animations: 'disabled', }); } const geometry = await page.evaluate(() => { const rect = (selector: string) => { const box = document.querySelector(selector)!.getBoundingClientRect(); return { x: box.x, y: box.y, width: box.width, height: box.height }; }; const pageStyle = getComputedStyle(document.querySelector('.legacy-board-page')!); const articleText = getComputedStyle(document.querySelector('.article-text')!); return { container: rect('#container'), topBar: rect('.top-back-bar'), titleLabel: rect('.form-label'), submitArticle: rect('#submitArticle'), articleAuthor: rect('.article-header .author-name'), articleDate: rect('.article-header .date'), icon: rect('.general-icon'), commentAuthor: rect('.comment-row .author-name'), submitComment: rect('.submit-comment'), bottomButton: rect('.bottom-bar .back-button'), font: { family: pageStyle.fontFamily, size: pageStyle.fontSize, lineHeight: pageStyle.lineHeight, }, whiteSpace: articleText.whiteSpace, walnut: getComputedStyle(document.querySelector('#newArticle')!).backgroundImage, green: getComputedStyle(document.querySelector('.article-header')!).backgroundImage, blue: getComputedStyle(document.querySelector('.new-article-header')!).backgroundImage, submitStyle: { backgroundColor: getComputedStyle(document.querySelector('#submitArticle')!) .backgroundColor, borderColor: getComputedStyle(document.querySelector('#submitArticle')!).borderColor, }, }; }); expect(geometry.container).toMatchObject({ width: 1000, height: 397.3125 }); expect(geometry.topBar.height).toBe(32); expect(geometry.titleLabel.width).toBeCloseTo(83.33, 1); expect(geometry.titleLabel).toMatchObject({ y: 64.1875, height: 22.1875 }); expect(geometry.submitArticle).toMatchObject({ y: 132.5625, height: 35.5 }); expect(geometry.submitArticle.width).toBeCloseTo(149.16, 1); expect(geometry.submitComment.width).toBeCloseTo(83.33, 1); expect(geometry.articleAuthor.width).toBe(120); expect(geometry.articleAuthor).toMatchObject({ y: 188.0625, height: 18.1875 }); expect(geometry.articleDate.width).toBeCloseTo(83.33, 1); expect(geometry.icon).toMatchObject({ x: 28, y: 206.25, width: 64, height: 64 }); expect(geometry.commentAuthor.width).toBe(120); expect(geometry.commentAuthor).toMatchObject({ y: 271.25, height: 20.1875 }); expect(geometry.submitComment).toMatchObject({ y: 292.4375, height: 29.375 }); expect(geometry.bottomButton).toMatchObject({ x: 0, y: 361.8125, width: 71, height: 35.5 }); expect(geometry.font.family).toContain('Pretendard'); expect(geometry.font).toMatchObject({ size: '14px', lineHeight: '18.2px' }); expect(geometry.whiteSpace).toBe('pre'); expect(geometry.walnut).toContain('https://sam-image.hided.net/game/back_walnut.jpg'); expect(geometry.green).toContain('https://sam-image.hided.net/game/back_green.jpg'); expect(geometry.blue).toContain('https://sam-image.hided.net/game/back_blue.jpg'); expect(geometry.submitStyle).toEqual({ backgroundColor: 'rgb(68, 68, 68)', borderColor: 'rgb(61, 61, 61)', }); const button = page.locator('#submitArticle'); const before = await button.evaluate((element) => getComputedStyle(element).backgroundColor); await button.hover(); const hover = await button.evaluate((element) => getComputedStyle(element).backgroundColor); await button.focus(); await expect(button).toBeFocused(); const focusOutline = await button.evaluate((element) => getComputedStyle(element).outline); expect(hover).toBe(before); expect(focusOutline).toContain('none'); }); test('uses the ref 500px responsive form widths', async ({ page }) => { const state: BoardFixture = { permission: 2, canMeeting: true, canSecret: true, articles: initialArticles(), requests: [], }; await installApi(page, state); await page.setViewportSize({ width: 500, height: 800 }); await gotoBoard(page, 'board/secret'); if (artifactRoot) { await page.screenshot({ path: resolve(artifactRoot, 'board-core-mobile-secret.png'), fullPage: true, animations: 'disabled', }); } const geometry = await page.evaluate(() => { const width = (selector: string) => document.querySelector(selector)!.getBoundingClientRect().width; return { container: width('#container'), label: width('.form-label'), submitArticle: width('#submitArticle'), author: width('.article-header .author-name'), date: width('.article-header .date'), }; }); expect(geometry.container).toBe(500); expect(geometry.label).toBeCloseTo(83.33, 1); expect(geometry.submitArticle).toBeCloseTo(152.66, 1); expect(geometry.author).toBe(120); expect(geometry.date).toBeCloseTo(83.33, 1); await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible(); }); test('retains article and comment input after a failed mutation, then reloads after success', async ({ page, }, testInfo) => { const state: BoardFixture = { permission: 2, canMeeting: true, canSecret: true, articles: initialArticles(), failArticle: true, failComment: true, requests: [], }; await installApi(page, state); await gotoBoard(page); await page.locator('#board-title').fill('새 제목'); await page.locator('#board-content').fill('새 내용'); await page.locator('#submitArticle').click(); const articleToast = page .getByTestId('game-toast') .filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' }); await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error'); await expect(articleToast).toHaveAttribute('role', 'alert'); await expect(page.locator('#board-title')).toHaveValue('새 제목'); await expect(page.locator('#board-content')).toHaveValue('새 내용'); const desktopToastGeometry = await articleToast.evaluate((element) => { const rect = element.getBoundingClientRect(); return { left: rect.left, right: rect.right, top: rect.top, width: rect.width, viewportWidth: window.innerWidth, }; }); expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0); expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth); expect(desktopToastGeometry.top).toBeGreaterThanOrEqual(0); expect(desktopToastGeometry.width).toBeGreaterThan(250); await page.screenshot({ path: testInfo.outputPath('game-toast-desktop.png'), fullPage: true }); await articleToast.getByRole('button', { name: '알림 닫기' }).click(); await expect(articleToast).toHaveCount(0); await page.locator('#submitArticle').click(); await expect(page.getByText('새 제목', { exact: true })).toBeVisible(); await expect(page.locator('#board-title')).toHaveValue(''); const commentInput = page.locator('.comment-input').first(); await commentInput.fill('새 댓글'); await page.setViewportSize({ width: 390, height: 844 }); const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth); await commentInput.press('Enter'); const commentToast = page .getByTestId('game-toast') .filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' }); await expect(commentToast).toBeVisible(); await expect .poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom) ) .toBeGreaterThanOrEqual(0); const mobileToastGeometry = await commentToast.evaluate((element) => { const rect = element.getBoundingClientRect(); return { left: rect.left, right: window.innerWidth - rect.right, bottom: window.innerHeight - rect.bottom, viewportWidth: window.innerWidth, documentWidth: document.documentElement.scrollWidth, }; }); expect(mobileToastGeometry.left).toBeGreaterThanOrEqual(0); expect(mobileToastGeometry.right).toBeGreaterThanOrEqual(0); expect(mobileToastGeometry.bottom).toBeGreaterThanOrEqual(0); expect(mobileToastGeometry.documentWidth).toBe(documentWidthBeforeToast); await page.screenshot({ path: testInfo.outputPath('game-toast-mobile.png') }); await expect(commentInput).toHaveValue('새 댓글'); await commentInput.press('Enter'); await expect(page.getByText('새 댓글', { exact: true })).toBeVisible(); }); test('denies direct secret-room rendering and disables its in-game menu for an ordinary member', async ({ page }) => { const state: BoardFixture = { permission: 0, canMeeting: true, canSecret: false, articles: initialArticles(), requests: [], }; await installApi(page, state); await page.goto('board/secret'); await expect(page.getByRole('alert')).toHaveText('권한이 부족합니다. 수뇌부가 아닙니다.'); await expect(page.locator('#newArticle')).toHaveCount(0); expect(state.requests).not.toContain('board.getArticles'); state.requests.length = 0; await page.goto(''); const nationMenu = page.locator('.main-nation-menu').first(); await expect(nationMenu.locator('[data-navigation-id="meeting"]')).toHaveAttribute('href', gamePath('/board')); await expect(nationMenu.locator('[data-navigation-id="secret-board"]')).toHaveAttribute('aria-disabled', 'true'); }); test('enables both in-game menu entries for a chief or delegated secret role', async ({ page }) => { const state: BoardFixture = { permission: 3, canMeeting: true, canSecret: true, articles: initialArticles(), requests: [], }; await installApi(page, state); await page.goto(''); const nationMenu = page.locator('.main-nation-menu').first(); await expect(nationMenu.locator('[data-navigation-id="meeting"]')).toHaveAttribute('href', gamePath('/board')); await expect(nationMenu.locator('[data-navigation-id="secret-board"]')).toHaveAttribute( 'href', gamePath('/board/secret') ); });