810 lines
36 KiB
TypeScript
810 lines
36 KiB
TypeScript
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||
import { basename, resolve } from 'node:path';
|
||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||
|
||
const response = (data: unknown) => ({ result: { data } });
|
||
const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR;
|
||
const legacyImageRoot = process.env.LEGACY_IMAGE_ROOT;
|
||
const operationNames = (route: Route) =>
|
||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||
|
||
const persistParityArtifact = async (page: Page, name: string, geometry: unknown) => {
|
||
if (!parityArtifactDir) {
|
||
return;
|
||
}
|
||
await mkdir(parityArtifactDir, { recursive: true });
|
||
await Promise.all([
|
||
page.screenshot({ path: resolve(parityArtifactDir, `${name}.png`), fullPage: true }),
|
||
writeFile(resolve(parityArtifactDir, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
|
||
]);
|
||
};
|
||
|
||
type FixtureState = {
|
||
permission: 'head' | 'member';
|
||
myset: number;
|
||
scenarioEffect?: string | null;
|
||
instantRetreatEnabled?: boolean;
|
||
instantRetreatAttempts?: number;
|
||
instantRetreatInputs?: Array<Record<string, unknown>>;
|
||
buildNationCandidateEnabled?: boolean;
|
||
buildNationCandidateAttempts?: number;
|
||
buildNationCandidateInputs?: Array<Record<string, unknown>>;
|
||
dieOnPrestartShow?: boolean;
|
||
dieOnPrestartAvailableAt?: string;
|
||
dieOnPrestartAttempts?: number;
|
||
dieOnPrestartInputs?: Array<Record<string, unknown>>;
|
||
generalMeQueries?: number;
|
||
generalLogQueries?: number;
|
||
ensurePrestartQueries?: number;
|
||
nationNoticeInput?: string;
|
||
settingMutations: Array<Record<string, unknown>>;
|
||
accessPages: string[];
|
||
};
|
||
|
||
type TrpcRequestPayload = {
|
||
json?: Record<string, unknown>;
|
||
input?: { json?: Record<string, unknown> };
|
||
};
|
||
|
||
const myGeneral = (state: FixtureState) => ({
|
||
general: {
|
||
id: 7,
|
||
name: '검증장수',
|
||
npcState: 0,
|
||
nationId: state.buildNationCandidateEnabled ? 0 : 1,
|
||
cityId: 1,
|
||
troopId: 0,
|
||
picture: null,
|
||
imageServer: 0,
|
||
officerLevel: state.permission === 'head' ? 5 : 1,
|
||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||
gold: 1_000,
|
||
rice: 2_000,
|
||
crew: 300,
|
||
train: 80,
|
||
atmos: 90,
|
||
injury: 0,
|
||
experience: 100,
|
||
dedication: 200,
|
||
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||
},
|
||
city: { id: 1, name: '업', level: 8, nationId: 1 },
|
||
nation: { id: 1, name: '위', color: '#777777', level: 3 },
|
||
settings: {
|
||
tnmt: 0,
|
||
defence_train: 80,
|
||
use_treatment: 21,
|
||
use_auto_nation_turn: 1,
|
||
myset: state.myset,
|
||
},
|
||
penalties: {},
|
||
});
|
||
|
||
const battleCenter = (state: FixtureState) => ({
|
||
me: {
|
||
id: 7,
|
||
officerLevel: state.permission === 'head' ? 5 : 1,
|
||
permissionLevel: state.permission === 'head' ? 2 : 0,
|
||
},
|
||
nation: { id: 1, name: '위', color: '#777777', level: 3 },
|
||
currentYear: 185,
|
||
currentMonth: 1,
|
||
turnTermMinutes: 10,
|
||
generals: [
|
||
{
|
||
id: 7,
|
||
name: '검증장수',
|
||
npcState: 0,
|
||
officerLevel: state.permission === 'head' ? 5 : 1,
|
||
cityId: 1,
|
||
turnTime: '2026-01-01 00:10:00',
|
||
recentWar: '2026-01-01 00:00:00',
|
||
warnum: 3,
|
||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||
experience: 100,
|
||
dedication: 200,
|
||
injury: 0,
|
||
gold: 1_000,
|
||
rice: 2_000,
|
||
crew: 300,
|
||
train: 80,
|
||
atmos: 90,
|
||
},
|
||
{
|
||
id: 8,
|
||
name: '다른장수',
|
||
npcState: 2,
|
||
officerLevel: 1,
|
||
cityId: 1,
|
||
turnTime: '2026-01-01 00:20:00',
|
||
recentWar: null,
|
||
warnum: 0,
|
||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||
experience: 0,
|
||
dedication: 0,
|
||
injury: 0,
|
||
gold: 500,
|
||
rice: 500,
|
||
crew: 100,
|
||
train: 60,
|
||
atmos: 60,
|
||
},
|
||
],
|
||
});
|
||
|
||
const install = async (page: Page, state: FixtureState) => {
|
||
await page.addInitScript(() => {
|
||
if (location.pathname.startsWith('/che/')) {
|
||
localStorage.setItem('sammo-game-token', 'ga_menu-token');
|
||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||
}
|
||
});
|
||
await page.route('**/image/game/**', async (route) => {
|
||
const filename = basename(new URL(route.request().url()).pathname);
|
||
if (legacyImageRoot && ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg'].includes(filename)) {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'image/jpeg',
|
||
body: await readFile(resolve(legacyImageRoot, filename)),
|
||
});
|
||
return;
|
||
}
|
||
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') });
|
||
});
|
||
await page.route('**/che/api/trpc/**', async (route) => {
|
||
const operations = operationNames(route);
|
||
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
||
const requestBody =
|
||
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
|
||
const results = operations.map((operation, operationIndex) => {
|
||
const rawPayload =
|
||
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
|
||
const payload =
|
||
rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined;
|
||
const jsonInput =
|
||
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
|
||
if (operation === 'auth.status') return response({ ok: true });
|
||
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
|
||
if (operation === 'join.getConfig') return response({});
|
||
if (operation === 'general.me') {
|
||
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
||
return response(myGeneral(state));
|
||
}
|
||
if (operation === 'general.ensureDieOnPrestartStatus') {
|
||
state.ensurePrestartQueries = (state.ensurePrestartQueries ?? 0) + 1;
|
||
return response({
|
||
show: state.dieOnPrestartShow ?? false,
|
||
available: false,
|
||
availableAt: state.dieOnPrestartAvailableAt ?? null,
|
||
});
|
||
}
|
||
if (operation === 'general.getFrontStatus')
|
||
return response({
|
||
onlineUserCount: 1,
|
||
onlineNations: '【위】',
|
||
onlineGenerals: '검증장수',
|
||
nationNotice: state.nationNoticeInput ?? '',
|
||
lastExecuted: '2026-01-01T00:00:00.000Z',
|
||
latestVote: null,
|
||
});
|
||
if (operation === 'world.getState')
|
||
return response({
|
||
currentYear: 185,
|
||
currentMonth: 1,
|
||
tickSeconds: 600,
|
||
config: {
|
||
npcMode: 0,
|
||
const: {
|
||
availableInstantAction: {
|
||
instantRetreat: state.instantRetreatEnabled ?? false,
|
||
},
|
||
},
|
||
environment: { scenarioEffect: state.scenarioEffect ?? null },
|
||
},
|
||
meta: {
|
||
turntime: '2026-01-01T00:00:00.000Z',
|
||
opentime: state.buildNationCandidateEnabled
|
||
? '2026-02-01T00:00:00.000Z'
|
||
: '2025-12-01T00:00:00.000Z',
|
||
autorun_user: {},
|
||
},
|
||
});
|
||
if (operation === 'world.getMapLayout')
|
||
return response({ mapName: 'che', cityList: [], regionMap: {}, levelMap: {} });
|
||
if (operation === 'world.getMap')
|
||
return response({
|
||
year: 185,
|
||
month: 1,
|
||
startYear: 180,
|
||
cityList: [],
|
||
nationList: [],
|
||
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 === 'messages.getRecent')
|
||
return response({
|
||
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,
|
||
});
|
||
if (operation === 'messages.getContacts') return response({ nation: [] });
|
||
if (operation === 'general.getRecentRecords') return response({ global: [], general: [], history: [] });
|
||
if (operation === 'board.getAccess') return response({ permission: 4, canMeeting: true, canSecret: true });
|
||
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||
if (operation === 'public.getTraffic')
|
||
return response({
|
||
history: [
|
||
{ year: 185, month: 1, date: '2026-01-01T00:00:00.000Z', refresh: 120, online: 8 },
|
||
{ year: 185, month: 2, date: '2026-01-01T00:10:00.000Z', refresh: 240, online: 12 },
|
||
],
|
||
maxRefresh: 240,
|
||
maxOnline: 12,
|
||
suspects: [
|
||
{ generalId: null, name: '합계', refresh: 360, refreshScoreTotal: 36 },
|
||
{ generalId: 7, name: '검증장수', refresh: 240, refreshScoreTotal: 24 },
|
||
],
|
||
});
|
||
if (operation === 'general.getMyLog') {
|
||
state.generalLogQueries = (state.generalLogQueries ?? 0) + 1;
|
||
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
|
||
}
|
||
if (operation === 'general.instantRetreat') {
|
||
state.instantRetreatInputs?.push(jsonInput);
|
||
state.instantRetreatAttempts = (state.instantRetreatAttempts ?? 0) + 1;
|
||
if (state.instantRetreatAttempts === 1) {
|
||
return {
|
||
error: {
|
||
message: '요청 처리 결과를 확인하지 못했습니다.',
|
||
code: -32000,
|
||
data: { code: 'TIMEOUT', httpStatus: 408, path: operation },
|
||
},
|
||
};
|
||
}
|
||
return response({ ok: true });
|
||
}
|
||
if (operation === 'general.buildNationCandidate') {
|
||
state.buildNationCandidateInputs?.push(jsonInput);
|
||
state.buildNationCandidateAttempts = (state.buildNationCandidateAttempts ?? 0) + 1;
|
||
if (state.buildNationCandidateAttempts === 1) {
|
||
return {
|
||
error: {
|
||
message: '요청 처리 결과를 확인하지 못했습니다.',
|
||
code: -32000,
|
||
data: { code: 'TIMEOUT', httpStatus: 408, path: operation },
|
||
},
|
||
};
|
||
}
|
||
return response({ ok: true });
|
||
}
|
||
if (operation === 'general.dieOnPrestart') {
|
||
state.dieOnPrestartInputs?.push(jsonInput);
|
||
state.dieOnPrestartAttempts = (state.dieOnPrestartAttempts ?? 0) + 1;
|
||
if (state.dieOnPrestartAttempts === 1) {
|
||
return {
|
||
error: {
|
||
message: '요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.',
|
||
code: -32000,
|
||
data: { code: 'TIMEOUT', httpStatus: 408, path: operation },
|
||
},
|
||
};
|
||
}
|
||
return response({ ok: true });
|
||
}
|
||
if (operation === 'general.setMySetting') {
|
||
state.settingMutations.push(jsonInput);
|
||
state.myset = Math.max(0, state.myset - 1);
|
||
return response({ ok: true });
|
||
}
|
||
if (operation === 'public.recordAccess') {
|
||
const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null;
|
||
if (pageName) state.accessPages.push(pageName);
|
||
return response({ recorded: true });
|
||
}
|
||
if (operation === 'nation.getBattleCenter') {
|
||
if (state.permission === 'member') {
|
||
return {
|
||
error: {
|
||
message: '권한이 부족합니다.',
|
||
code: -32000,
|
||
data: { code: 'FORBIDDEN', httpStatus: 403, path: operation },
|
||
},
|
||
};
|
||
}
|
||
return response(battleCenter(state));
|
||
}
|
||
if (operation === 'nation.getGeneralLog') {
|
||
const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction')
|
||
? 'generalAction'
|
||
: operation;
|
||
return response({ type, generalId: 7, logs: [{ id: 1, text: '<Y>감찰 기록</>' }] });
|
||
}
|
||
return response({ ok: true });
|
||
});
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify(operations.length === 1 ? results[0] : results),
|
||
});
|
||
});
|
||
};
|
||
|
||
test('정화된 국가 방침은 실행 가능한 속성 없이 Chromium에 표시된다', async ({ page }) => {
|
||
const state: FixtureState = {
|
||
permission: 'head',
|
||
myset: 0,
|
||
nationNoticeInput: [
|
||
'<p data-flip="horizontal" style="color:#00ffff">안전한 방침</p>',
|
||
'<img src="/image/icons/default.jpg" />',
|
||
'<a>위험 링크</a>',
|
||
].join(''),
|
||
settingMutations: [],
|
||
accessPages: [],
|
||
};
|
||
await install(page, state);
|
||
|
||
await page.goto('');
|
||
|
||
const notice = page.locator('.nation-notice-body');
|
||
await expect(notice).toContainText('안전한 방침');
|
||
await expect(notice.locator('[data-flip="horizontal"]')).toHaveCSS('color', 'rgb(0, 255, 255)');
|
||
await expect(notice.locator('script, svg, [onerror], [onload], [onclick]')).toHaveCount(0);
|
||
await expect(notice.locator('a', { hasText: '위험 링크' })).not.toHaveAttribute('href');
|
||
await expect
|
||
.poll(() => page.evaluate(() => (globalThis as typeof globalThis & { __nationXss?: number }).__nationXss))
|
||
.toBeUndefined();
|
||
});
|
||
|
||
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
|
||
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
|
||
await install(page, state);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('traffic');
|
||
await expect(page.locator('.chart-title').first()).toHaveText('접 속 량');
|
||
await expect.poll(() => state.accessPages).toContain('traffic');
|
||
|
||
const geometry = await page.locator('#traffic-container').evaluate((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const title = element.querySelector<HTMLElement>('.title-table')!.getBoundingClientRect();
|
||
const charts = [...element.querySelectorAll<HTMLElement>('.chart-table')].map((chart) =>
|
||
chart.getBoundingClientRect()
|
||
);
|
||
const row = element.querySelector<HTMLElement>('.chart-row')!.getBoundingClientRect();
|
||
const bar = element.querySelector<HTMLElement>('.big-bar')!.getBoundingClientRect();
|
||
const suspect = element.querySelector<HTMLElement>('.suspect-table')!.getBoundingClientRect();
|
||
return {
|
||
width: rect.width,
|
||
minWidth: getComputedStyle(element).minWidth,
|
||
fontSize: getComputedStyle(element).fontSize,
|
||
fontFamily: getComputedStyle(element).fontFamily,
|
||
titleWidth: title.width,
|
||
chartWidths: charts.map((chart) => chart.width),
|
||
chartGap: charts[1]!.x - charts[0]!.right,
|
||
rowHeight: row.height,
|
||
barHeight: bar.height,
|
||
suspectWidth: suspect.width,
|
||
};
|
||
});
|
||
expect(geometry.width).toBe(1016);
|
||
expect(geometry.minWidth).toBe('1016px');
|
||
expect(geometry.fontSize).toBe('14px');
|
||
expect(geometry.fontFamily).toContain('Pretendard');
|
||
expect(geometry.titleWidth).toBe(1000);
|
||
expect(geometry.chartWidths).toEqual([483, 483]);
|
||
expect(geometry.chartGap).toBe(26);
|
||
expect(geometry.rowHeight).toBe(31);
|
||
expect(geometry.barHeight).toBe(30);
|
||
expect(geometry.suspectWidth).toBeGreaterThanOrEqual(994);
|
||
await persistParityArtifact(page, 'traffic-desktop', geometry);
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
const mobileWidth = await page
|
||
.locator('#traffic-container')
|
||
.evaluate((element) => element.getBoundingClientRect().width);
|
||
expect(mobileWidth).toBe(1016);
|
||
});
|
||
|
||
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||
await install(page, state);
|
||
await page.setViewportSize({ width: 1000, height: 900 });
|
||
await page.goto('my-page');
|
||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||
await expect(page.locator('#set_my_setting')).toBeVisible();
|
||
await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0);
|
||
expect(state.accessPages).not.toContain('my-page');
|
||
const noDefenceOption = page.locator('option[value="999"]');
|
||
await expect(noDefenceOption).toHaveText('× [훈련 -3,사기 -6]');
|
||
await expect(page.locator('#defence_train option')).toHaveText([
|
||
'☆(훈사90)',
|
||
'◎(훈사80)',
|
||
'○(훈사60)',
|
||
'△(훈사40)',
|
||
'× [훈련 -3,사기 -6]',
|
||
]);
|
||
|
||
const desktop = await page.locator('#container').evaluate((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const title = element.querySelector<HTMLElement>('.title-row')!.getBoundingClientRect();
|
||
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
||
const saveButton = element.querySelector<HTMLElement>('#set_my_setting')!;
|
||
const save = saveButton.getBoundingClientRect();
|
||
const customCss = element.querySelector<HTMLElement>('#custom_css')!.getBoundingClientRect();
|
||
const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns;
|
||
return {
|
||
width: rect.width,
|
||
minWidth: getComputedStyle(element).minWidth,
|
||
fontSize: getComputedStyle(element).fontSize,
|
||
columns,
|
||
titleHeight: title.height,
|
||
settingsOffset: settings.x - rect.x,
|
||
saveWidth: save.width,
|
||
saveHeight: save.height,
|
||
saveBackground: getComputedStyle(saveButton).backgroundColor,
|
||
customCssWidth: customCss.width,
|
||
customCssHeight: customCss.height,
|
||
backgroundImage: getComputedStyle(element).backgroundImage,
|
||
sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage,
|
||
};
|
||
});
|
||
expect(desktop.width).toBe(1000);
|
||
expect(desktop.minWidth).toBe('500px');
|
||
expect(desktop.fontSize).toBe('14px');
|
||
expect(desktop.columns.split(' ')).toHaveLength(2);
|
||
expect(desktop.titleHeight).toBeCloseTo(54, 0);
|
||
expect(desktop.settingsOffset).toBeCloseTo(500, 0);
|
||
expect(desktop.saveWidth).toBe(160);
|
||
expect(desktop.saveHeight).toBe(30);
|
||
expect(desktop.saveBackground).toBe('rgb(34, 85, 0)');
|
||
expect(desktop.customCssWidth).toBe(420);
|
||
expect(desktop.customCssHeight).toBe(150);
|
||
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
|
||
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
|
||
await persistParityArtifact(page, 'core-my-page-desktop', desktop);
|
||
|
||
const defenceSelect = page.locator('select').filter({ has: page.locator('option[value="999"]') });
|
||
await defenceSelect.selectOption('999');
|
||
const noEffectState = await defenceSelect.evaluate((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = getComputedStyle(element);
|
||
return {
|
||
scenarioEffect: null,
|
||
optionText: element.querySelector<HTMLOptionElement>('option[value="999"]')?.textContent,
|
||
selectedText: element.querySelector<HTMLOptionElement>('option:checked')?.textContent,
|
||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||
fontSize: style.fontSize,
|
||
lineHeight: style.lineHeight,
|
||
color: style.color,
|
||
backgroundColor: style.backgroundColor,
|
||
};
|
||
});
|
||
expect(noEffectState.rect.width).toBe(134);
|
||
expect(noEffectState.rect.height).toBe(20);
|
||
await persistParityArtifact(page, 'core-my-page-no-effect-999', noEffectState);
|
||
await page.locator('#set_my_setting').click();
|
||
await expect.poll(() => state.settingMutations.length).toBe(1);
|
||
expect(state.settingMutations[0]).not.toHaveProperty('generalId');
|
||
|
||
for (const [effectIndex, scenarioEffect] of [
|
||
'event_UnlimitedDefenceThresholdChange',
|
||
'event_StrongAttacker',
|
||
'event_MoreEffect',
|
||
].entries()) {
|
||
state.scenarioEffect = scenarioEffect;
|
||
state.myset = 1;
|
||
await page.reload();
|
||
await expect(noDefenceOption).toHaveText('×');
|
||
await defenceSelect.selectOption('999');
|
||
const effectState = await defenceSelect.evaluate((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = getComputedStyle(element);
|
||
return {
|
||
optionText: element.querySelector<HTMLOptionElement>('option[value="999"]')?.textContent,
|
||
selectedText: element.querySelector<HTMLOptionElement>('option:checked')?.textContent,
|
||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||
fontSize: style.fontSize,
|
||
lineHeight: style.lineHeight,
|
||
color: style.color,
|
||
backgroundColor: style.backgroundColor,
|
||
};
|
||
});
|
||
expect(effectState.optionText).toBe('×');
|
||
expect(effectState.selectedText).toBe('×');
|
||
expect(effectState.rect.width).toBe(86);
|
||
expect(effectState.rect.height).toBe(20);
|
||
await persistParityArtifact(page, `core-my-page-${scenarioEffect}`, effectState);
|
||
await page.locator('#set_my_setting').click();
|
||
await expect.poll(() => state.settingMutations.length).toBe(effectIndex + 2);
|
||
expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId');
|
||
}
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await page.reload();
|
||
const mobile = await page.locator('#container').evaluate((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
||
return {
|
||
width: rect.width,
|
||
scrollWidth: document.documentElement.scrollWidth,
|
||
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
|
||
settingsOffset: settings.x - rect.x,
|
||
settingsWidth: settings.width,
|
||
};
|
||
});
|
||
expect(mobile).toMatchObject({
|
||
width: 500,
|
||
scrollWidth: 500,
|
||
columns: '500px',
|
||
settingsOffset: 0,
|
||
settingsWidth: 500,
|
||
});
|
||
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
||
});
|
||
|
||
test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => {
|
||
const state: FixtureState = {
|
||
permission: 'head',
|
||
myset: 3,
|
||
instantRetreatEnabled: true,
|
||
instantRetreatAttempts: 0,
|
||
instantRetreatInputs: [],
|
||
generalMeQueries: 0,
|
||
generalLogQueries: 0,
|
||
ensurePrestartQueries: 0,
|
||
settingMutations: [],
|
||
accessPages: [],
|
||
};
|
||
const dialogs: string[] = [];
|
||
page.on('dialog', async (dialog) => {
|
||
dialogs.push(`${dialog.type()}:${dialog.message()}`);
|
||
await dialog.accept();
|
||
});
|
||
await install(page, state);
|
||
await page.goto('my-page');
|
||
|
||
const instantRetreatButton = page.getByRole('button', { name: '접경 귀환' });
|
||
await expect(instantRetreatButton).toBeVisible();
|
||
await expect.poll(() => state.generalMeQueries).toBe(1);
|
||
await expect.poll(() => state.ensurePrestartQueries).toBe(1);
|
||
|
||
await instantRetreatButton.click();
|
||
await expect.poll(() => state.instantRetreatInputs?.length).toBe(1);
|
||
await expect
|
||
.poll(() => dialogs.some((message) => message.includes('요청 처리 결과를 확인하지 못했습니다.')))
|
||
.toBe(true);
|
||
await expect.poll(() => state.generalMeQueries).toBe(2);
|
||
await expect.poll(() => state.ensurePrestartQueries).toBe(2);
|
||
|
||
await instantRetreatButton.click();
|
||
await expect.poll(() => state.instantRetreatInputs?.length).toBe(2);
|
||
await expect.poll(() => state.generalMeQueries).toBe(3);
|
||
await expect.poll(() => state.ensurePrestartQueries).toBe(3);
|
||
await expect.poll(() => state.generalLogQueries).toBe(12);
|
||
await page.evaluate(() => new Promise<void>((resolveFrame) => requestAnimationFrame(() => resolveFrame())));
|
||
|
||
await instantRetreatButton.click();
|
||
await expect.poll(() => state.instantRetreatInputs?.length).toBe(3);
|
||
|
||
const requestIds = state.instantRetreatInputs?.map((input) => input.clientRequestId);
|
||
expect(requestIds?.[0]).toEqual(expect.stringMatching(/^[0-9a-f-]{36}$/i));
|
||
expect(requestIds?.[1]).toBe(requestIds?.[0]);
|
||
expect(requestIds?.[2]).toEqual(expect.stringMatching(/^[0-9a-f-]{36}$/i));
|
||
expect(requestIds?.[2]).not.toBe(requestIds?.[1]);
|
||
expect(state.instantRetreatInputs?.every((input) => !('generalId' in input))).toBe(true);
|
||
});
|
||
|
||
test('가오픈 장수 삭제는 레거시 표시와 확인을 보존하고 timeout을 같은 ID로 재시도한다', async ({ page }) => {
|
||
const state: FixtureState = {
|
||
permission: 'head',
|
||
myset: 3,
|
||
dieOnPrestartShow: true,
|
||
dieOnPrestartAvailableAt: '2026-01-01T00:20:00.000Z',
|
||
dieOnPrestartAttempts: 0,
|
||
dieOnPrestartInputs: [],
|
||
settingMutations: [],
|
||
accessPages: [],
|
||
};
|
||
const dialogs: string[] = [];
|
||
let confirmCount = 0;
|
||
page.on('dialog', async (dialog) => {
|
||
dialogs.push(`${dialog.type()}:${dialog.message()}`);
|
||
if (dialog.type() === 'confirm') {
|
||
confirmCount += 1;
|
||
if (confirmCount === 1) {
|
||
await dialog.dismiss();
|
||
return;
|
||
}
|
||
}
|
||
await dialog.accept();
|
||
});
|
||
await install(page, state);
|
||
await page.addInitScript(() => {
|
||
localStorage.setItem('sammo-session-token', 'gateway-session-token');
|
||
});
|
||
await page.route(/^http:\/\/127\.0\.0\.1:\d+\/api\/trpc\/me(?:\?|$)/u, async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify(
|
||
response({
|
||
id: 'gateway-user',
|
||
username: 'gateway-user',
|
||
displayName: '게이트웨이 사용자',
|
||
})
|
||
),
|
||
});
|
||
});
|
||
await page.route('**/gateway/**', async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'text/html',
|
||
body: '<!doctype html><html><body>gateway</body></html>',
|
||
});
|
||
});
|
||
await page.setViewportSize({ width: 1000, height: 900 });
|
||
await page.goto('my-page');
|
||
|
||
const actionLine = page.locator('.action-line').filter({ hasText: '가오픈 기간 내 장수 삭제' });
|
||
const deleteButton = actionLine.getByRole('button', { name: '장수 삭제' });
|
||
await expect(actionLine).toContainText('가오픈 기간 내 장수 삭제 (2026-01-01 09:20:00 부터)');
|
||
await expect(deleteButton).toBeVisible();
|
||
await expect(deleteButton).toBeEnabled();
|
||
const geometry = await deleteButton.evaluate((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const style = getComputedStyle(element);
|
||
return {
|
||
width: rect.width,
|
||
height: rect.height,
|
||
fontSize: style.fontSize,
|
||
color: style.color,
|
||
backgroundColor: style.backgroundColor,
|
||
cursor: style.cursor,
|
||
};
|
||
});
|
||
expect(geometry).toEqual({
|
||
width: 160,
|
||
height: 30,
|
||
fontSize: '14px',
|
||
color: 'rgb(255, 255, 255)',
|
||
backgroundColor: 'rgb(34, 85, 0)',
|
||
cursor: 'pointer',
|
||
});
|
||
await persistParityArtifact(page, 'core-my-page-die-on-prestart', geometry);
|
||
|
||
await deleteButton.click();
|
||
await expect.poll(() => confirmCount).toBe(1);
|
||
expect(dialogs[0]).toBe('confirm:정말로 삭제하시겠습니까?');
|
||
expect(state.dieOnPrestartInputs).toHaveLength(0);
|
||
|
||
const timeoutReload = page.waitForEvent('framenavigated', (frame) => frame === page.mainFrame());
|
||
await deleteButton.click();
|
||
await expect.poll(() => state.dieOnPrestartInputs?.length).toBe(1);
|
||
await expect
|
||
.poll(() =>
|
||
dialogs.some((message) =>
|
||
message.includes('alert:실패했습니다: 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.')
|
||
)
|
||
)
|
||
.toBe(true);
|
||
await timeoutReload;
|
||
await page.waitForLoadState('networkidle');
|
||
await expect(deleteButton).toBeVisible();
|
||
const pendingRequestId = await page.evaluate(() => sessionStorage.getItem('sam.pending.dieOnPrestart'));
|
||
expect(pendingRequestId).toEqual(expect.stringMatching(/^[0-9a-f-]{36}$/i));
|
||
|
||
await deleteButton.click();
|
||
await expect.poll(() => state.dieOnPrestartInputs?.length).toBe(2);
|
||
await page.waitForURL(/\/gateway\/$/u);
|
||
await expect(page.locator('body')).toHaveText('gateway');
|
||
|
||
const requestIds = state.dieOnPrestartInputs?.map((input) => input.clientRequestId);
|
||
expect(requestIds).toEqual([pendingRequestId, pendingRequestId]);
|
||
expect(state.dieOnPrestartInputs?.every((input) => !('generalId' in input) && !('userId' in input))).toBe(true);
|
||
const storage = await page.evaluate(() => ({
|
||
gameToken: localStorage.getItem('sammo-game-token'),
|
||
gameProfile: localStorage.getItem('sammo-game-profile'),
|
||
sessionToken: localStorage.getItem('sammo-session-token'),
|
||
pendingRequestId: sessionStorage.getItem('sam.pending.dieOnPrestart'),
|
||
}));
|
||
expect(storage).toEqual({
|
||
gameToken: null,
|
||
gameProfile: 'che:default',
|
||
sessionToken: 'gateway-session-token',
|
||
pendingRequestId: null,
|
||
});
|
||
});
|
||
|
||
test('사전 거병은 timeout reload 뒤 같은 ID를 재시도하고 성공 reload 뒤 새 ID를 만든다', async ({ page }) => {
|
||
const state: FixtureState = {
|
||
permission: 'head',
|
||
myset: 3,
|
||
buildNationCandidateEnabled: true,
|
||
buildNationCandidateAttempts: 0,
|
||
buildNationCandidateInputs: [],
|
||
ensurePrestartQueries: 0,
|
||
settingMutations: [],
|
||
accessPages: [],
|
||
};
|
||
page.on('dialog', (dialog) => dialog.accept());
|
||
await install(page, state);
|
||
await page.goto('my-page');
|
||
|
||
const build = page.getByRole('button', { name: '사전 거병' });
|
||
await expect(build).toBeVisible();
|
||
await expect.poll(() => state.ensurePrestartQueries).toBe(1);
|
||
|
||
await build.click();
|
||
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(1);
|
||
await expect.poll(() => state.ensurePrestartQueries).toBe(2);
|
||
|
||
await build.click();
|
||
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(2);
|
||
await expect.poll(() => state.ensurePrestartQueries).toBe(3);
|
||
|
||
await build.click();
|
||
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(3);
|
||
const requestIds = state.buildNationCandidateInputs?.map((input) => input.clientRequestId);
|
||
expect(requestIds?.[1]).toBe(requestIds?.[0]);
|
||
expect(requestIds?.[2]).not.toBe(requestIds?.[1]);
|
||
});
|
||
|
||
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
|
||
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||
await install(page, head);
|
||
await page.setViewportSize({ width: 1000, height: 900 });
|
||
await page.goto('battle-center');
|
||
await expect(page.getByRole('heading', { name: '감찰부' })).toBeVisible();
|
||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
|
||
await page.getByRole('button', { name: '다음 ▶' }).click();
|
||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
|
||
const geometry = await page.locator('.battle-page').evaluate((element) => {
|
||
const selector = element.querySelector<HTMLElement>('.selector-row')!;
|
||
const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect());
|
||
const logBlock = element.querySelector<HTMLElement>('.log-block')!.getBoundingClientRect();
|
||
return {
|
||
width: element.getBoundingClientRect().width,
|
||
fontSize: getComputedStyle(element).fontSize,
|
||
selectorColumns: getComputedStyle(selector).gridTemplateColumns,
|
||
selectorHeight: selector.getBoundingClientRect().height,
|
||
controlWidths: controls.map((control) => control.width),
|
||
logBlockWidth: logBlock.width,
|
||
backgroundImage: getComputedStyle(element).backgroundImage,
|
||
generalBackgroundImage: getComputedStyle(element.querySelector<HTMLElement>('.battle-general-card')!)
|
||
.backgroundImage,
|
||
};
|
||
});
|
||
expect(geometry.width).toBe(1000);
|
||
expect(geometry.fontSize).toBe('14px');
|
||
expect(geometry.selectorColumns.split(' ')).toHaveLength(4);
|
||
expect(geometry.selectorHeight).toBeCloseTo(36, 0);
|
||
expect(geometry.controlWidths[0]).toBeCloseTo(83.33, 0);
|
||
expect(geometry.controlWidths[1]).toBeCloseTo(333.33, 0);
|
||
expect(geometry.logBlockWidth).toBeCloseTo(500, 0);
|
||
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
|
||
expect(geometry.generalBackgroundImage).toContain('back_blue.jpg');
|
||
await persistParityArtifact(page, 'core-battle-center-desktop', geometry);
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({
|
||
columns: getComputedStyle(element).gridTemplateColumns,
|
||
controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width),
|
||
}));
|
||
expect(mobileGeometry.columns.split(' ')).toHaveLength(4);
|
||
expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0);
|
||
expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0);
|
||
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
|
||
|
||
await page.unrouteAll({ behavior: 'wait' });
|
||
const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [], accessPages: [] };
|
||
await install(page, member);
|
||
await page.reload();
|
||
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
|
||
});
|