Files
core2026/app/gateway-frontend/e2e/lobby-game-auth.spec.ts
T

813 lines
35 KiB
TypeScript

import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const artifactRoot = process.env.GATEWAY_STATUS_ARTIFACT_DIR;
if (artifactRoot) {
mkdirSync(artifactRoot, { recursive: true });
}
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: {
'access-control-allow-origin': '*',
},
body: JSON.stringify(results),
});
};
type LobbyFixtureOptions = {
authenticated?: boolean;
roles?: string[];
kakaoVerified?: boolean;
canCreateGeneral?: boolean;
requiresKakaoVerification?: boolean;
specialAccess?: {
kind: 'OPERATOR' | 'TESTER' | 'RECOVERY' | 'OTHER';
grantId: string | null;
expiresAt: string | null;
allowsGeneralCreation: boolean;
} | null;
myGeneral?: {
name: string;
picture: string;
imageServer: number;
} | null;
selectionPoolEnabled?: boolean;
npcPossessionEnabled?: boolean;
userCnt?: number;
maxUserCnt?: number;
nationCnt?: number;
isUnited?: number;
starttime?: string;
opentime?: string;
preopenAt?: string;
turntime?: string;
turnTerm?: number;
scenarioTitle?: string;
npcMode?: number;
defaultStatTotal?: number;
korName?: string;
otherTextInfo?: string;
autorunUser?: {
limitMinutes: number;
options: string[];
} | null;
lobbyBundleFailures?: number;
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
includeStoppedProfile?: boolean;
};
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
const {
authenticated = true,
roles = ['user'],
kakaoVerified = true,
canCreateGeneral = true,
requiresKakaoVerification = false,
specialAccess = null,
myGeneral = {
name: '선택장수',
picture: 'users/core2026/account-hash.png',
imageServer: 1,
},
selectionPoolEnabled = true,
npcPossessionEnabled = false,
userCnt = 1,
maxUserCnt = 500,
nationCnt = 0,
isUnited = 0,
starttime = '2026-07-30 00:00:00',
opentime = '2026-07-30 00:00:00',
preopenAt = '',
turntime = '2026-07-30 00:05:00',
turnTerm = 5,
scenarioTitle = '',
npcMode = 0,
defaultStatTotal = 165,
korName = 'hwe',
otherTextInfo = '',
autorunUser = null,
lobbyBundleFailures = 0,
profileStatus = 'RUNNING',
includeStoppedProfile = false,
} = options;
let remainingLobbyBundleFailures = lobbyBundleFailures;
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
if (authenticated) {
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session');
});
}
await page.route('**/gateway/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'me') {
return response(
authenticated
? {
id: 'lobby-user',
username: 'lobby-user',
displayName: '로비사용자',
roles,
kakaoVerified,
createdAt: '2026-07-30T00:00:00.000Z',
}
: null
);
}
if (operation === 'lobby.notice') {
return response('');
}
if (operation === 'lobby.profiles') {
return response(
[
{
profileName: 'hwe:903',
profile: 'hwe',
instanceKey: '903',
currentScenario: '903',
scenario: '903',
status: profileStatus,
lifecycle: {
runtimeExpected: profileStatus !== 'STOPPED',
userAccessible: profileStatus !== 'STOPPED',
turnsRunning: profileStatus === 'RUNNING',
operatorResumable: profileStatus === 'PAUSED' || profileStatus === 'STOPPED',
dataInitialized: true,
},
apiPort: 15015,
runtime: {
apiRunning: true,
daemonRunning: true,
auctionRunning: true,
battleSimRunning: true,
tournamentRunning: true,
},
korName,
color: '#ffffff',
localAccountPolicy: {
accessAllowed: true,
canCreateGeneral,
requiresKakaoVerification,
graceEndsAt: null,
specialAccess,
},
},
includeStoppedProfile
? {
profileName: 'che:2601',
profile: 'che',
instanceKey: '2601',
currentScenario: '2601',
scenario: '2601',
status: 'STOPPED',
lifecycle: {
runtimeExpected: false,
userAccessible: false,
turnsRunning: false,
operatorResumable: true,
dataInitialized: true,
},
apiPort: 15003,
runtime: {
apiRunning: false,
daemonRunning: false,
auctionRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
korName: 'che',
color: '#f59e0b',
localAccountPolicy: {
accessAllowed: false,
canCreateGeneral: false,
requiresKakaoVerification: false,
graceEndsAt: null,
specialAccess: null,
},
}
: null,
].filter((profile) => profile !== null)
);
}
if (operation === 'auth.issueGameSession') {
return response({
profile: 'hwe:903',
gameToken: 'encrypted-gateway-game-token',
expiresAt: '2026-07-30T01:00:00.000Z',
});
}
throw new Error(`Unhandled gateway tRPC operation: ${operation}`);
});
await fulfillTrpc(route, results);
});
await page.route('**/hwe/api/trpc/**', async (route) => {
expect(new URL(route.request().url()).pathname).toContain('/hwe/api/trpc/');
const authorization = route.request().headers().authorization;
const operations = operationNames(route);
if (operations.includes('lobby.info') && remainingLobbyBundleFailures > 0) {
remainingLobbyBundleFailures -= 1;
gameOperations.push(...operations.map((operation) => ({ operation, authorization })));
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({ error: 'profile runtime is switching' }),
});
return;
}
const results = operations.map((operation) => {
gameOperations.push({ operation, authorization });
if (operation === 'auth.exchangeGatewayToken') {
return response({
accessToken: 'ga_lobby-access-token',
profile: 'hwe:903',
expiresAt: '2026-07-30T01:00:00.000Z',
});
}
if (operation === 'lobby.info') {
return response({
year: 180,
month: 1,
userCnt,
maxUserCnt,
npcCnt: 0,
nationCnt,
turnTerm,
fictionMode: '가상',
starttime,
opentime,
preopenAt,
turntime,
otherTextInfo,
scenarioTitle,
npcMode,
defaultStatTotal,
autorunUser,
isUnited,
selectionPoolEnabled,
npcPossessionEnabled,
myGeneral,
});
}
if (operation === 'public.getMapLayout') {
return response({ mapName: 'che', cityList: [] });
}
if (operation === 'public.getCachedMap') {
return response({ year: 180, month: 1, cityList: [], nationList: [] });
}
throw new Error(`Unhandled game tRPC operation: ${operation}`);
});
await fulfillTrpc(route, results);
});
await page.route('https://sam-image.hided.net/icons/users/core2026/account-hash.png', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/png',
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
),
});
});
return gameOperations;
};
test('exchanges the gateway token before loading authenticated lobby general data', async ({ page }) => {
const gameOperations = await installFixture(page);
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await expect(row).toContainText('선택장수');
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
const portrait = row.locator('img');
await expect(portrait).toHaveAttribute('src', 'https://sam-image.hided.net/icons/users/core2026/account-hash.png');
await expect.poll(() => portrait.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
expect(gameOperations.find(({ operation }) => operation === 'auth.exchangeGatewayToken')).toEqual({
operation: 'auth.exchangeGatewayToken',
authorization: undefined,
});
expect(gameOperations.find(({ operation }) => operation === 'lobby.info')).toEqual({
operation: 'lobby.info',
authorization: 'Bearer ga_lobby-access-token',
});
});
test('copies the complete preopen announcement and reveals autorun details without changing layout', async ({
page,
}, testInfo) => {
await installFixture(page, {
roles: ['superuser'],
kakaoVerified: false,
profileStatus: 'PREOPEN',
korName: '훼',
specialAccess: {
kind: 'OPERATOR',
grantId: null,
expiresAt: null,
allowsGeneralCreation: true,
},
preopenAt: '2026-08-19 22:00:00',
opentime: '2026-08-19 23:00:00',
starttime: '2026-08-19 23:00:00',
turnTerm: 1,
scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)',
npcMode: 0,
defaultStatTotal: 310,
autorunUser: {
limitMinutes: 1_440,
options: ['develop', 'warp', 'recruit_high', 'train', 'battle', 'chief'],
},
});
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: '훼섭' });
const serverName = row.locator('.profile-server-cell .font-bold');
const settings = row.locator('.profile-announcement-settings');
const autorun = row.locator('.copyable-autorun');
const detail = row.locator('.copyable-autorun-detail');
await expect(row.getByTestId('profile-preopen-at')).toHaveText('- 가오픈 일시 : 2026-08-19 22:00:00 -');
await expect(row.getByTestId('profile-open-at')).toHaveText('- 오픈 일시 : 2026-08-19 23:00:00 -');
await expect(row.getByTestId('profile-scenario-announcement')).toHaveText(
'【가상모드27-b】 아시아 명장전(비급) 1분 턴 서버'
);
const settingsText = (await settings.textContent())?.replace(/\s+/g, ' ').trim();
expect(settingsText).toBe(
'(상성 설정:가상), (빙의 여부:불가), (최대 스탯:310), ' +
'(기타 설정:자율행동[내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효])'
);
await expect(detail).toHaveCSS('font-size', '0px');
await expect(detail).toHaveCSS('color', 'rgba(0, 0, 0, 0)');
await expect(page.getByText('특수 접근 · OPERATOR')).toHaveCount(0);
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
const baseGeometry = await row.evaluate((element) => ({
row: element.getBoundingClientRect().toJSON(),
documentWidth: document.documentElement.scrollWidth,
}));
await autorun.hover();
await expect(detail).toBeVisible();
await expect(detail).toContainText('내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효');
const hoverGeometry = await row.evaluate((element) => ({
row: element.getBoundingClientRect().toJSON(),
documentWidth: document.documentElement.scrollWidth,
}));
expect(hoverGeometry).toEqual(baseGeometry);
await page.screenshot({ path: testInfo.outputPath('gateway-autorun-announcement-hover.png'), fullPage: true });
await autorun.focus();
await expect(autorun).toBeFocused();
await expect(detail).toBeVisible();
await expect(autorun).toHaveCSS('outline-width', '2px');
await page.mouse.click(8, 8);
const start = await serverName.boundingBox();
const end = await settings.boundingBox();
if (!start || !end) throw new Error('expected announcement selection geometry');
await page.mouse.move(start.x + 1, start.y + start.height / 2);
await page.mouse.down();
await page.mouse.move(end.x + end.width - 1, end.y + end.height / 2, { steps: 24 });
await page.mouse.up();
const selectedText = await page.evaluate(() => window.getSelection()?.toString() ?? '');
const compactSelection = selectedText.replace(/\s+/g, ' ').trim();
expect(compactSelection).toContain(
'훼섭 - 가오픈 일시 : 2026-08-19 22:00:00 - - 오픈 일시 : 2026-08-19 23:00:00 - ' +
'【가상모드27-b】 아시아 명장전(비급) 1분 턴 서버 ' +
'(상성 설정:가상), (빙의 여부:불가), (최대 스탯:310), ' +
'(기타 설정:자율행동[내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효])'
);
expect(compactSelection).not.toContain('OPERATOR');
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await page.setViewportSize({ width: 390, height: 844 });
await autorun.scrollIntoViewIfNeeded();
const mobileBase = await row.evaluate((element) => ({
row: element.getBoundingClientRect().toJSON(),
documentWidth: document.documentElement.scrollWidth,
}));
await autorun.hover();
await expect(detail).toBeVisible();
const mobileHover = await row.evaluate((element) => {
const tooltip = element.querySelector('.copyable-autorun-detail');
if (!tooltip) throw new Error('expected autorun tooltip');
return {
row: element.getBoundingClientRect().toJSON(),
tooltip: tooltip.getBoundingClientRect().toJSON(),
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
};
});
expect(mobileHover.row).toEqual(mobileBase.row);
expect(mobileHover.documentWidth).toBe(mobileHover.viewportWidth);
expect(mobileHover.tooltip.left).toBeGreaterThanOrEqual(8);
expect(mobileHover.tooltip.right).toBeLessThanOrEqual(mobileHover.viewportWidth - 8);
await page.screenshot({ path: testInfo.outputPath('gateway-autorun-announcement-mobile.png'), fullPage: true });
});
test('loads and labels a PAUSED profile whose runtime remains available', async ({ page }, testInfo) => {
const gameOperations = await installFixture(page, { profileStatus: 'PAUSED' });
await page.setViewportSize({ width: 1365, height: 900 });
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
const pausedStatus = row.getByTestId('profile-paused-status');
await expect(pausedStatus).toHaveText('턴 일시정지 · 조회/예약턴 가능');
await expect(pausedStatus).toHaveCSS('color', 'oklch(0.879 0.169 91.605)');
await expect(row).toContainText('선택장수');
await expect(row).not.toContainText('정보를 불러오는 중');
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
expect(gameOperations.some(({ operation }) => operation === 'lobby.info')).toBe(true);
await expect(page.getByRole('tab', { name: 'hwe섭' })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('gateway-paused-profile-lobby.png'), fullPage: true });
});
test('does not contact a STOPPED game runtime and labels it inaccessible', async ({ page }, testInfo) => {
const gameOperations = await installFixture(page, { profileStatus: 'STOPPED' });
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await expect(row).toContainText('서버 중지 · 접근 불가');
await expect(row).not.toContainText('정보를 불러오는 중');
await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'hwe섭' })).toHaveCount(0);
expect(gameOperations).toEqual([]);
await page.screenshot({ path: testInfo.outputPath('gateway-stopped-profile-lobby.png'), fullPage: true });
});
test('automatically recovers profile details after a transient update outage', async ({ page }) => {
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await expect(row.getByTestId('profile-info-retrying')).toContainText('서버 응답을 기다리고 있습니다.');
await expect(row.getByRole('button', { name: '지금 다시 확인' })).toBeVisible();
await expect(row).toContainText('선택장수', { timeout: 8_000 });
expect(gameOperations.filter(({ operation }) => operation === 'lobby.info')).toHaveLength(2);
});
test('uses two-row mobile server cards without horizontal scrolling and keeps retry keyboard-accessible', async ({
page,
}, testInfo) => {
// Keep the scheduled retry in the error state so it cannot race the manual retry after the screenshot.
await installFixture(page, { lobbyBundleFailures: 2, includeStoppedProfile: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
const retry = row.getByRole('button', { name: '지금 다시 확인' });
const tableScroll = page.getByTestId('profile-table-scroll');
await expect(retry).toBeVisible();
await retry.focus();
await expect(retry).toBeFocused();
const geometry = await tableScroll.evaluate((scrollElement) => {
const rows = [...scrollElement.querySelectorAll('tbody tr')];
const row = rows[0];
const button = row?.querySelector('button');
const serverCell = row?.querySelector('.profile-server-cell');
const infoCell = row?.querySelector('.profile-info-cell');
const portraitCell = row?.querySelector('.profile-portrait-cell');
const generalCell = row?.querySelector('.profile-general-cell');
const actionCell = row?.querySelector('.profile-action-cell');
if (!row) throw new Error('expected profile row');
if (!button) throw new Error('expected profile retry button');
if (!serverCell || !infoCell || !portraitCell || !generalCell || !actionCell) {
throw new Error('expected all profile cells');
}
const scrollRect = scrollElement.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
const style = getComputedStyle(button);
const rect = (element: Element) => {
const value = element.getBoundingClientRect();
return { top: value.top, right: value.right, bottom: value.bottom, left: value.left, width: value.width };
};
return {
pageScrollWidth: document.documentElement.scrollWidth,
scroll: {
left: scrollRect.left,
right: scrollRect.right,
clientWidth: scrollElement.clientWidth,
scrollWidth: scrollElement.scrollWidth,
},
row: { left: rowRect.left, right: rowRect.right, width: rowRect.width },
rows: rows.map((element) => rect(element)),
button: { left: buttonRect.left, right: buttonRect.right, width: buttonRect.width },
cells: {
server: rect(serverCell),
info: rect(infoCell),
portrait: rect(portraitCell),
general: rect(generalCell),
action: rect(actionCell),
},
viewportWidth: window.innerWidth,
outlineStyle: style.outlineStyle,
outlineWidth: style.outlineWidth,
};
});
expect(geometry.pageScrollWidth).toBe(geometry.viewportWidth);
expect(geometry.scroll.clientWidth).toBeLessThanOrEqual(geometry.viewportWidth);
expect(geometry.scroll.scrollWidth).toBe(geometry.scroll.clientWidth);
expect(geometry.row.width).toBe(geometry.scroll.clientWidth);
expect(geometry.rows).toHaveLength(2);
expect(geometry.rows[0]?.bottom).toBeLessThanOrEqual(geometry.rows[1]?.top ?? 0);
expect(geometry.rows.every((item) => item.width === geometry.scroll.clientWidth)).toBe(true);
expect(geometry.button.left).toBeGreaterThanOrEqual(geometry.scroll.left);
expect(geometry.button.right).toBeLessThanOrEqual(geometry.scroll.right);
expect(geometry.cells.server.top).toBeCloseTo(geometry.cells.info.top, 0);
expect(geometry.cells.server.left).toBeCloseTo(geometry.row.left, 0);
expect(geometry.cells.info.right).toBeCloseTo(geometry.row.right, 0);
expect(geometry.cells.portrait.top).toBeCloseTo(geometry.cells.general.top, 0);
expect(geometry.cells.general.top).toBeCloseTo(geometry.cells.action.top, 0);
expect(geometry.cells.portrait.top).toBeGreaterThanOrEqual(geometry.cells.server.bottom);
expect(geometry.cells.portrait.left).toBeCloseTo(geometry.row.left, 0);
expect(geometry.cells.action.right).toBeCloseTo(geometry.row.right, 0);
expect(geometry.outlineStyle).toBe('solid');
expect(geometry.outlineWidth).toBe('2px');
await retry.click();
await expect(row).toContainText('선택장수');
await expect(row.getByTestId('profile-info-retrying')).toHaveCount(0);
const enterButton = row.getByRole('button', { name: '입장' });
await expect(enterButton).toBeVisible();
const enterBackground = await enterButton.evaluate((element) => getComputedStyle(element).backgroundColor);
await enterButton.hover();
await expect
.poll(() => enterButton.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(enterBackground);
await page.screenshot({ path: testInfo.outputPath('gateway-profile-two-row-mobile.png'), fullPage: true });
await page.setViewportSize({ width: 320, height: 700 });
await expect
.poll(() =>
tableScroll.evaluate((element) => ({
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
rowWidth: element.querySelector('tbody tr')?.getBoundingClientRect().width,
}))
)
.toEqual({ documentWidth: 320, viewportWidth: 320, clientWidth: 286, scrollWidth: 286, rowWidth: 286 });
await page.setViewportSize({ width: 799, height: 900 });
await expect
.poll(() =>
tableScroll.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
rowDisplay: getComputedStyle(element.querySelector('tbody tr')!).display,
}))
)
.toEqual({ clientWidth: 765, scrollWidth: 765, rowDisplay: 'grid' });
await page.setViewportSize({ width: 800, height: 900 });
await expect
.poll(() =>
tableScroll.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
rowDisplay: getComputedStyle(element.querySelector('tbody tr')!).display,
}))
)
.toEqual({ clientWidth: 766, scrollWidth: 766, rowDisplay: 'table-row' });
});
test('applies the signed general-acquisition policy to both create and possession actions', async ({ page }) => {
await installFixture(page, {
canCreateGeneral: false,
myGeneral: null,
selectionPoolEnabled: false,
npcPossessionEnabled: true,
});
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await expect(row.getByRole('button', { name: '인증 필요' })).toBeDisabled();
await expect(row.getByRole('button', { name: '장수빙의' })).toBeDisabled();
});
test('shows the Kakao verification banner when a profile still requires verification', async ({ page }) => {
await installFixture(page, {
kakaoVerified: false,
requiresKakaoVerification: true,
});
await page.goto('lobby');
await expect(page.getByText('카카오 인증이 필요합니다.')).toBeVisible();
});
test('hides the Kakao verification banner for operator special access', async ({ page }) => {
await installFixture(page, {
roles: ['superuser'],
kakaoVerified: false,
specialAccess: {
kind: 'OPERATOR',
grantId: null,
expiresAt: null,
allowsGeneralCreation: true,
},
});
await page.goto('lobby');
await expect(page.getByText('특수 접근 · OPERATOR')).toHaveCount(0);
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
});
test('hides the Kakao verification banner when a grant removes the remaining verification requirement', async ({
page,
}) => {
await installFixture(page, {
kakaoVerified: false,
specialAccess: {
kind: 'RECOVERY',
grantId: '11111111-1111-4111-8111-111111111111',
expiresAt: '2026-08-20T00:00:00.000Z',
allowsGeneralCreation: true,
},
});
await page.goto('lobby');
await expect(page.getByText('특수 접근 · RECOVERY')).toHaveCount(0);
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
});
test('opens the profile root without profile or game token query parameters', async ({ page }) => {
await installFixture(page);
await page.route('**/hwe/', async (route) => {
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<title>Clean profile target</title>',
});
});
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await row.getByRole('button', { name: '입장', exact: true }).click();
await expect(page).toHaveURL(/\/hwe\/$/);
const target = new URL(page.url());
expect(target.search).toBe('');
expect(
await page.evaluate(() => JSON.parse(window.sessionStorage.getItem('sammo-pending-game-session') ?? 'null'))
).toEqual({
profile: 'hwe:903',
gatewayToken: 'encrypted-gateway-game-token',
});
});
test('opens the mode-1 possession route with a fresh gateway game token outside the URL', async ({ page }) => {
await installFixture(page, {
myGeneral: null,
selectionPoolEnabled: false,
npcPossessionEnabled: true,
});
await page.route('**/hwe/join?**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<title>NPC possession target</title>',
});
});
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await expect(row.getByRole('button', { name: '장수빙의' })).toBeEnabled();
await row.getByRole('button', { name: '장수빙의' }).click();
await expect(page).toHaveURL(/\/hwe\/join\?tab=possess$/);
const target = new URL(page.url());
expect(target.searchParams.get('tab')).toBe('possess');
expect(target.searchParams.has('profile')).toBe(false);
expect(target.searchParams.has('gameToken')).toBe(false);
expect(
await page.evaluate(() => JSON.parse(window.sessionStorage.getItem('sammo-pending-game-session') ?? 'null'))
).toEqual({
profile: 'hwe:903',
gatewayToken: 'encrypted-gateway-game-token',
});
});
test('shows registration closed instead of acquisition actions at the Ref capacity boundary', async ({ page }) => {
await installFixture(page, {
myGeneral: null,
selectionPoolEnabled: false,
npcPossessionEnabled: true,
userCnt: 300,
maxUserCnt: 300,
});
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await expect(row).toContainText('장수 등록 마감');
await expect(row.getByRole('button', { name: '장수생성' })).toHaveCount(0);
await expect(row.getByRole('button', { name: '장수빙의' })).toHaveCount(0);
});
for (const season of [
{
name: 'competition',
isUnited: 0,
opentime: '2000-01-01 00:00:00',
label: '<4국 경쟁중>',
period: '2026-07-30 00:00:00 ~',
},
{
name: 'preopen',
isUnited: 0,
opentime: '2099-01-01 00:00:00',
label: '-가오픈 중-',
period: '2026-07-30 00:00:00 ~',
},
{
name: 'event running',
isUnited: 1,
opentime: '2099-01-01 00:00:00',
label: '§이벤트 진행중§',
period: '2026-07-30 00:00:00 ~',
},
{
name: 'united',
isUnited: 2,
opentime: '2099-01-01 00:00:00',
label: '§천하통일§',
period: '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00',
},
{
name: 'event finished',
isUnited: 3,
opentime: '2099-01-01 00:00:00',
label: '§이벤트 종료§',
period: '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00',
},
] as const) {
test(`renders the Ref ${season.name} season status without changing entry actions`, async ({ page }) => {
await installFixture(page, {
isUnited: season.isUnited,
opentime: season.opentime,
nationCnt: 4,
});
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
const status = row.getByText(season.label, { exact: true });
await expect(status).toBeVisible();
await expect(row.locator('td').first().locator('[title]')).toHaveAttribute('title', season.period);
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
if (artifactRoot) {
const [viewport, rowGeometry, statusGeometry] = await Promise.all([
page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
})),
row.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}),
status.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: style.color,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
textAlign: style.textAlign,
};
}),
]);
const geometry = { viewport, row: rowGeometry, status: statusGeometry };
const slug = season.name.replaceAll(' ', '-');
writeFileSync(resolve(artifactRoot, `gateway-${slug}.json`), `${JSON.stringify(geometry, null, 2)}\n`);
await page.screenshot({ path: resolve(artifactRoot, `gateway-${slug}.png`), fullPage: true });
}
});
}
test('renders the same Ref season status on the public gateway page', async ({ page }) => {
await installFixture(page, { authenticated: false, isUnited: 3, nationCnt: 4 });
await page.goto('');
const status = page.locator('.season-status');
await expect(status).toHaveText('§이벤트 종료§');
await expect(status).toHaveAttribute('title', '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00');
await expect(page.getByRole('button', { name: '현황 새로고침' })).toBeEnabled();
});