Files
core2026/app/game-frontend/e2e/diplomacy.spec.ts
T

163 lines
7.9 KiB
TypeScript

import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
const artifactRoot = process.env.DIPLOMACY_ARTIFACT_DIR ? resolve(process.env.DIPLOMACY_ARTIFACT_DIR) : null;
const response = (data: unknown) => ({ result: { data } });
const operationName = (route: Route): string => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6));
};
const installFixture = async (page: Page, permission: number) => {
await page.addInitScript(
(profile) => {
localStorage.setItem('sammo-game-token', 'ga_diplomacy_html_playwright');
localStorage.setItem('sammo-game-profile', profile);
delete (globalThis as Record<string, unknown>).__diplomacyXss;
},
`${basePath.slice(1)}:default`
);
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
const results = operationName(route)
.split(',')
.map((operation) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '정화외교관' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'diplomacy.getLetters') {
return response({
myNationId: 1,
permission,
nations: [{ id: 2, name: '상대국', color: '#ff0000', level: 5 }],
letters: [
{
id: 7,
src: {
nationId: 1,
nationName: '정화국',
nationColor: '#00ffff',
generalId: 1,
generalName: '정화외교관',
generalIcon: null,
},
dest: {
nationId: 2,
nationName: '상대국',
nationColor: '#ff0000',
generalId: null,
generalName: null,
generalIcon: null,
},
prevId: null,
state: 'PROPOSED',
stateOpt: null,
brief: '<p><strong>서버 정화 공개문</strong></p><img src="/image/icons/default.jpg" alt="문서" />',
detail:
permission >= 3
? '<ul><li>서버 정화 기밀문</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>'
: '(권한이 부족합니다)',
date: '2026-07-31T00:00:00.000Z',
reason: { who: null, action: null, text: null },
},
],
});
}
throw new Error(`Unhandled diplomacy fixture operation: ${operation}`);
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
await page.route('**/image/icons/default.jpg', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/png',
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
),
});
});
};
const screenshot = async (page: Page, name: string) => {
if (!artifactRoot) return;
await mkdir(artifactRoot, { recursive: true });
await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true });
};
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
] as const) {
test(`renders only server-purified diplomacy HTML on ${viewport.name}`, async ({ page }) => {
await installFixture(page, 4);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('diplomacy');
const card = page.locator('.letter-card');
await expect(card).toBeVisible();
await expect(card.locator('strong')).toHaveText('서버 정화 공개문');
await expect(card.locator('li')).toHaveText('서버 정화 기밀문');
await expect(card.getByRole('link', { name: '자료' })).toHaveAttribute('href', 'https://example.com');
await expect(card.getByRole('link', { name: '자료' })).toHaveAttribute('rel', 'noopener noreferrer nofollow');
await expect(card.locator('.letter-text script, .letter-text svg, .letter-text math')).toHaveCount(0);
await expect(card.locator('.letter-text [onerror], .letter-text [onclick], .letter-text [style]')).toHaveCount(0);
expect(await page.evaluate(() => (globalThis as Record<string, unknown>).__diplomacyXss)).toBeUndefined();
const geometry = await card.evaluate((element) => {
const rect = element.getBoundingClientRect();
const text = element.querySelector<HTMLElement>('.letter-text')!;
const textRect = text.getBoundingClientRect();
const style = getComputedStyle(text);
return {
card: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
documentWidth: document.documentElement.scrollWidth,
labelWidth: element.querySelector<HTMLElement>('.row-label')?.getBoundingClientRect().width,
headerFontSize: getComputedStyle(element.querySelector('h3')!).fontSize,
text: {
x: textRect.x,
y: textRect.y,
width: textRect.width,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
},
};
});
expect(geometry.card.width).toBe(1000);
expect(geometry.documentWidth).toBe(viewport.name === 'mobile' ? 1000 : viewport.width);
expect(geometry.labelWidth).toBe(200);
expect(geometry.headerFontSize).toBe('28px');
expect(geometry.text.width).toBe(800);
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await writeFile(
resolve(artifactRoot, `diplomacy-html-${basePath.slice(1)}-${viewport.name}.json`),
`${JSON.stringify({ basePath, viewport, geometry }, null, 2)}\n`,
'utf8'
);
}
const send = page.getByRole('button', { name: '전송' });
await send.focus();
await expect(send).toBeFocused();
await send.hover();
await screenshot(page, `diplomacy-html-${basePath.slice(1)}-${viewport.name}.png`);
});
}
test('keeps diplomacy detail redacted below permission three', async ({ page }) => {
await installFixture(page, 2);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('diplomacy');
const card = page.locator('.letter-card');
await expect(card).toContainText('(권한이 부족합니다)');
await expect(card).not.toContainText('서버 정화 기밀문');
await expect(page.getByText('문서 작성 권한은 군주/수뇌에게만 제공됩니다.')).toBeVisible();
await expect(page.getByRole('button', { name: '전송' })).toHaveCount(0);
});