import { expect, test, type Page, type Route } from '@playwright/test'; import { canonicalFrontendFixture as fixture } from './fixtures/canonical.js'; const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'; 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 pathname = new URL(route.request().url()).pathname; return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(','); }; const general = { id: 1, name: '테스트장수', npcState: 0, nationId: 1, cityId: 1, troopId: 0, picture: 'default.jpg', imageServer: 0, officerLevel: 1, stats: { leadership: 80, strength: 70, intelligence: 90 }, gold: 1000, rice: 1000, crew: 500, train: 100, atmos: 100, injury: 0, experience: 1200, dedication: 900, items: { horse: null, weapon: null, book: null, item: null }, }; const generalContext = { general, city: { id: 1, name: '낙양', level: 7, nationId: 1, population: 50000, agriculture: 5000, commerce: 5000, security: 5000, defence: 5000, wall: 5000, supplyState: 1, frontState: 2, }, nation: { id: 1, name: '테스트국', color: '#d32f2f', level: 5, gold: 10000, rice: 10000, tech: 1200, typeCode: 'che_군벌', capitalCityId: 1, }, settings: {}, penalties: {}, }; const target = (generalId: number, generalName: string, nationId: number, nationName: string, color: string) => ({ generalId, generalName, nationId, nationName, color, icon: '/image/icons/default.jpg', }); const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f'); const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6'); const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19); const buildMessages = (permission: number) => ({ result: true, public: [ { id: 101, msgType: 'public', src: ownTarget, dest: null, text: '전체 메시지 본문', option: {}, time: messageTime, }, ], national: [ { id: 102, msgType: 'national', src: ownTarget, dest: target(0, '', 1, '테스트국', '#d32f2f'), text: '국가 메시지 본문', option: {}, time: messageTime, }, ], private: [ { id: 103, msgType: 'private', src: foreignTarget, dest: ownTarget, text: '개인 메시지 본문', option: {}, time: messageTime, }, ], diplomacy: [ { id: 104, msgType: 'diplomacy', src: foreignTarget, dest: target(0, '', 1, '테스트국', '#d32f2f'), text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)', option: permission >= 3 ? { action: 'noAggression', deletable: false } : { action: 'noAggression', deletable: false, invalid: true }, time: messageTime, }, ], sequence: 104, nationId: 1, generalName: general.name, permission, canRespondDiplomacy: permission >= 4 && general.officerLevel > 4, latestRead: { private: 0, diplomacy: 0 }, }); const contacts = { nation: [ { nationId: 0, mailbox: 9000, name: '재야', color: '#000000', general: [], }, { nationId: 1, mailbox: 9001, name: '테스트국', color: '#d32f2f', general: [ [1, '테스트장수', 4], [2, '아군군주', 1], ], }, { nationId: 2, mailbox: 9002, name: '상대국', color: '#2457a6', general: [ [8, '상대외교관', 4], [9, '상대일반', 0], ], }, ], }; const installFixture = async ( page: Page, options: { permission: number; sendError?: string } ): Promise> => { const mutations: Array<{ operation: string; body: unknown }> = []; await page.addInitScript( ({ gameToken, profile }) => { window.localStorage.setItem('sammo-game-token', gameToken); window.localStorage.setItem('sammo-game-profile', profile); }, { gameToken: fixture.game.session.gameToken, profile: fixture.game.session.profile, } ); await page.route('**/image/**', (route) => route.fulfill({ status: 204, body: '' })); await page.route('**/che/api/events**', (route) => route.abort()); await page.route('**/che/api/trpc/**', async (route) => { const body = route.request().postDataJSON(); const results = operationNames(route).map((operation) => { if (operation === 'auth.status') return response({ userId: 'frontend-parity-user' }); if (operation === 'lobby.info') { return response({ ...fixture.game.lobby, myGeneral: general }); } if (operation === 'general.me') return response(generalContext); if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout); if (operation === 'world.getMap') { return response({ ...fixture.game.map, myCity: 1, myNation: 1 }); } if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { return response({ turns: [], revision: 0 }); } if (operation === 'general.getFrontStatus') { return response({ onlineUserCount: 1, onlineNations: '테스트국(1)', onlineGenerals: general.name, nationNotice: '', lastExecuted: null, latestVote: null, }); } if (operation === 'general.getRecentRecords') { return response({ global: [], general: [], history: [] }); } if (operation === 'messages.getRecent') return response(buildMessages(options.permission)); if (operation === 'messages.getContacts') return response(contacts); if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true }); if (operation === 'tournament.getState') return response({ stage: 0 }); if (operation === 'public.recordAccess') return response({ recorded: true }); if ( operation === 'messages.send' || operation === 'messages.readLatest' || operation === 'messages.delete' || operation === 'messages.respond' ) { mutations.push({ operation, body }); if (operation === 'messages.send' && options.sendError) { return errorResponse(operation, options.sendError); } return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true }); } return errorResponse(operation, `Unhandled message fixture operation: ${operation}`); }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results), }); }); return mutations; }; const openMessages = async (page: Page, viewport: { width: number; height: number }) => { await page.setViewportSize(viewport); await page.goto(`http://127.0.0.1:${gamePort}/che/`); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); if (viewport.width <= 1024) { await page.getByRole('button', { name: '메시지', exact: true }).click(); } await expect(page.locator('.MessagePanel')).toBeVisible(); }; for (const viewport of [ { width: 1000, height: 900 }, { width: 500, height: 900 }, ]) { test(`matches the reference message computed DOM at ${viewport.width}px Chromium viewport`, async ({ page }) => { await installFixture(page, { permission: 4 }); await openMessages(page, viewport); const geometry = await page.locator('.MessagePanel').evaluate((panel) => { const required = (selector: string) => panel.querySelector(selector)!; const rect = (element: Element) => { const box = element.getBoundingClientRect(); return { x: box.x, y: box.y, width: box.width, height: box.height }; }; const panelStyle = getComputedStyle(panel); const header = required('.BoardHeader'); const plate = required('.msg-plate'); const icon = required('.general-icon'); return { panel: rect(panel), inputForm: rect(required('.MessageInputForm')), select: rect(required('.message-select')), input: rect(required('.message-text')), submit: rect(required('.message-send')), publicSection: rect(required('.PublicTalk')), nationalSection: rect(required('.NationalTalk')), firstHeader: rect(header), firstPlate: rect(plate), firstIcon: rect(icon), computed: { panelDisplay: panelStyle.display, panelColumns: panelStyle.gridTemplateColumns, panelFontSize: panelStyle.fontSize, headerColor: getComputedStyle(header).color, headerOutlineWidth: getComputedStyle(header).outlineWidth, plateBackgroundColor: getComputedStyle(plate).backgroundColor, plateFontSize: getComputedStyle(plate).fontSize, plateMinHeight: getComputedStyle(plate).minHeight, iconObjectFit: getComputedStyle(icon).objectFit, }, }; }); expect(geometry.panel.x).toBeCloseTo(0, 0); expect(geometry.panel.width).toBeCloseTo(viewport.width, 0); expect(geometry.inputForm.width).toBeCloseTo(viewport.width, 0); expect(geometry.select.height).toBeCloseTo(35.5, 0); expect(geometry.submit.height).toBeCloseTo(35.5, 0); expect(geometry.firstHeader.height).toBeCloseTo(25, 0); expect(geometry.firstPlate.height).toBeGreaterThanOrEqual(64); expect(geometry.firstIcon).toMatchObject({ width: 64, height: 64 }); expect(geometry.computed).toMatchObject({ panelFontSize: '14px', headerColor: 'rgb(255, 255, 255)', headerOutlineWidth: '1px', plateBackgroundColor: 'rgb(20, 28, 101)', plateFontSize: '12.5px', plateMinHeight: '64px', iconObjectFit: 'fill', }); if (viewport.width === 1000) { expect(geometry.computed.panelDisplay).toBe('grid'); expect(geometry.computed.panelColumns).toBe('500px 500px'); expect(geometry.select.width).toBeCloseTo(166.66, 0); expect(geometry.input.width).toBeCloseTo(666.66, 0); expect(geometry.submit.width).toBeCloseTo(166.66, 0); expect(geometry.publicSection.width).toBeCloseTo(500, 0); expect(geometry.nationalSection.x).toBeCloseTo(500, 0); } else { expect(geometry.computed.panelDisplay).toBe('block'); expect(geometry.select.width).toBeCloseTo(250, 0); expect(geometry.input.width).toBeCloseTo(500, 0); expect(geometry.input.height).toBeCloseTo(33.5, 0); expect(geometry.submit.width).toBeCloseTo(250, 0); } const submit = page.locator('.message-send'); await submit.hover(); expect( await submit.evaluate((element) => ({ cursor: getComputedStyle(element).cursor, backgroundColor: getComputedStyle(element).backgroundColor, })) ).toEqual({ cursor: 'pointer', backgroundColor: 'rgb(55, 90, 127)' }); await submit.focus(); expect( await submit.evaluate((element) => ({ outlineWidth: getComputedStyle(element).outlineWidth, boxShadow: getComputedStyle(element).boxShadow, })) ).toEqual({ outlineWidth: '0px', boxShadow: 'none' }); }); } test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => { const mutations = await installFixture(page, { permission: 4 }); await openMessages(page, { width: 500, height: 900 }); const select = page.getByLabel('메시지 수신 대상'); await expect(select.locator('option[value="9002"]')).toHaveCount(1); await expect(select.locator('option[value="8"]')).toBeDisabled(); await expect(select.locator('option[value="9"]')).toBeEnabled(); await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대장수' }).click(); await expect(select).toHaveValue('8'); await page.locator('.PrivateTalk').getByRole('button', { name: '모두 읽음' }).click(); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.readLatest').length).toBe(1); const deleteButton = page.locator('.PublicTalk .delete-message'); page.once('dialog', (dialog) => dialog.accept()); await deleteButton.click(); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1); await select.selectOption('9999'); await page.getByLabel('메시지 입력').fill('전송 성공'); await page.getByRole('button', { name: '서신전달&갱신' }).click(); await expect(page.getByLabel('메시지 입력')).toHaveValue(''); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1); }); test('redacts diplomacy for a low-permission general and preserves the failed-send error flow', async ({ page }) => { const mutations = await installFixture(page, { permission: 2, sendError: '공개 메세지를 보낼 수 없습니다.', }); await openMessages(page, { width: 500, height: 900 }); const select = page.getByLabel('메시지 수신 대상'); await expect(select.locator('option[value="9002"]')).toHaveCount(0); await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다'); await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문'); await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled(); await select.selectOption('9999'); await page.getByLabel('메시지 입력').fill('차단될 메시지'); await page.getByRole('button', { name: '서신전달&갱신' }).click(); await expect(page.getByLabel('메시지 입력')).toHaveValue(''); await expect(page.getByRole('alert').filter({ hasText: '공개 메세지를 보낼 수 없습니다.' })).toBeVisible(); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1); });