Files
core2026/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts
T
Hide_D 1d8f80f381 fix(gateway): 모바일 계정 버튼 행을 정리
일반 사용자는 두 버튼을 한 행에 유지하고 관리자 링크는 모바일에서 전체폭 둘째 행으로 분리한다. 500px과 더 좁은 viewport의 Chromium 좌표 및 상호작용 회귀를 추가한다.
2026-08-21 01:52:22 +00:00

376 lines
18 KiB
TypeScript

import { expect, test, type Page, type Route } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installGatewayFixture = async (page: Page, roles: string[]) => {
const requests: Array<{ method: string; url: string; body: unknown }> = [];
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'playwright-admin-session');
});
await page.route('**/gateway/api/trpc/**', async (route) => {
requests.push({
method: route.request().method(),
url: route.request().url(),
body: route.request().postDataJSON(),
});
const results = operationNames(route).map((operation) => {
if (operation === 'me') {
return response({
id: 'admin-user',
username: 'admin',
displayName: '관리자',
roles,
createdAt: '2026-07-25T00:00:00.000Z',
});
}
if (operation === 'lobby.notice' || operation === 'admin.system.getNotice') {
return response(operation === 'lobby.notice' ? '' : { notice: '' });
}
if (
operation === 'lobby.profiles' ||
operation === 'admin.profiles.listScenarios' ||
operation === 'admin.operations.list' ||
operation === 'admin.releases.list'
) {
return response([]);
}
if (operation === 'admin.profiles.list') {
return response(
roles.some((role) => role.includes(':hwe:2'))
? [
{
profileName: 'hwe:2',
profile: 'hwe',
instanceKey: '2',
currentScenario: '1010',
scenario: '1010',
status: 'RUNNING',
buildStatus: 'SUCCEEDED',
meta: { korName: '환상서버' },
runtime: {},
runtimeActions: [],
},
]
: []
);
}
if (operation === 'admin.profiles.listNavigation') {
return response(
roles.some((role) => role === 'superuser' || role.includes(':hwe:2'))
? [
{
profileName: 'hwe:2',
profile: 'hwe',
instanceKey: '2',
currentScenario: '1010',
meta: { korName: '환상서버' },
},
]
: []
);
}
if (operation === 'admin.releases.gatewayState') {
return response({ id: 'gateway', updatedAt: '2026-08-01T00:00:00.000Z' });
}
if (operation === 'admin.users.getLocalAccountStatus') {
return response({ enabled: true });
}
if (operation === 'admin.capabilities.list') {
return response(
roles.includes('superuser')
? [
{ permission: 'admin.users.manage', scope: 'GLOBAL', scopes: ['*'] },
{ permission: 'admin.profiles.runtime', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.profiles.settings', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.profiles.deploy', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['*'] },
{ permission: 'admin.releases.manage', scope: 'GLOBAL', scopes: ['*'] },
{ permission: 'admin.notice.manage', scope: 'GLOBAL', scopes: ['*'] },
{ permission: 'admin.audit.read', scope: 'GLOBAL', scopes: ['*'] },
]
: [
{
permission: roles[0]?.split(':')[0],
scope: 'PROFILE',
scopes: ['hwe:2'],
},
]
);
}
throw new Error(`Unhandled tRPC operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
new URL(route.request().url()).searchParams.get('batch') === '1' ? results : results[0]
),
});
});
return requests;
};
test('bootstrap superuser can navigate the administrator workspace from the lobby', async ({ page }, testInfo) => {
const requests = await installGatewayFixture(page, ['superuser']);
await page.goto('lobby');
const adminLink = page.getByRole('link', { name: '관리자 페이지' });
await expect(adminLink).toBeVisible();
await adminLink.click();
await expect(page).toHaveURL(/\/gateway\/admin$/);
await expect(page.getByRole('heading', { name: '운영 개요' })).toBeVisible();
await expect(page.getByText('관리 대상을 선택하세요.')).toBeVisible();
await expect(page.getByRole('region', { name: '관리 원칙' })).toHaveCount(0);
await expect(page.getByText('작업 영역이 분리되었습니다.')).toHaveCount(0);
const overviewGrid = page.getByRole('region', { name: '관리 기능' });
const desktopOverviewGeometry = await overviewGrid.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width };
});
expect(desktopOverviewGeometry.top).toBeLessThan(300);
await writeFile(
testInfo.outputPath('admin-overview-desktop-geometry.json'),
JSON.stringify(desktopOverviewGeometry)
);
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
await expect(navigation).toBeVisible();
const userLink = navigation.getByRole('link', { name: '사용자 관리' });
const baseColor = await userLink.evaluate((element) => getComputedStyle(element).color);
await userLink.hover();
await expect.poll(() => userLink.evaluate((element) => getComputedStyle(element).color)).not.toBe(baseColor);
await page.screenshot({ path: testInfo.outputPath('admin-overview-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(navigation).toBeHidden();
await page.getByRole('button', { name: '관리자 메뉴' }).click();
await expect(navigation).toBeVisible();
const geometry = await navigation.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, viewportWidth: window.innerWidth };
});
expect(geometry.left).toBeGreaterThanOrEqual(0);
expect(geometry.right).toBeLessThanOrEqual(geometry.viewportWidth);
expect(geometry.top).toBeGreaterThanOrEqual(0);
await writeFile(testInfo.outputPath('admin-overview-mobile-geometry.json'), JSON.stringify(geometry));
await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-menu.png'), fullPage: true });
await page.getByRole('button', { name: '관리자 메뉴' }).click();
await expect(navigation).toBeHidden();
await page.evaluate(() => window.scrollTo(0, 0));
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
const mobileOverviewGeometry = await overviewGrid.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width };
});
expect(mobileOverviewGeometry.left).toBeGreaterThanOrEqual(0);
expect(mobileOverviewGeometry.right).toBeLessThanOrEqual(390);
expect(mobileOverviewGeometry.top).toBeLessThan(340);
await writeFile(
testInfo.outputPath('admin-overview-mobile-content-geometry.json'),
JSON.stringify(mobileOverviewGeometry)
);
await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-content.png'), fullPage: true });
await page.getByRole('button', { name: '관리자 메뉴' }).click();
await navigation.getByRole('link', { name: 'Gateway 릴리스' }).click();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await expect(page.getByRole('heading', { name: 'Gateway 릴리스', level: 1 })).toBeVisible();
expect(requests.length).toBeGreaterThan(0);
expect(requests.every(({ method }) => method === 'POST')).toBe(true);
expect(requests.every(({ url }) => !new URL(url).searchParams.has('input'))).toBe(true);
expect(requests.some(({ body }) => JSON.stringify(body).includes('"limit":30'))).toBe(true);
});
test('desktop administrator sidebar follows the navbar away and then sticks to the viewport top', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1200, height: 500 });
await installGatewayFixture(page, ['superuser']);
await page.goto('admin');
const sidebar = page.locator('#admin-navigation');
await expect(sidebar).toBeVisible();
const measurements: Array<{
scrollY: number;
top: number;
bottom: number;
height: number;
viewportHeight: number;
position: string;
backgroundColor: string;
}> = [];
for (const scrollY of [0, 20, 75, 76, 140]) {
await page.evaluate((top) => window.scrollTo(0, top), scrollY);
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollY);
const geometry = await sidebar.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
viewportHeight: window.innerHeight,
position: style.position,
backgroundColor: style.backgroundColor,
};
});
expect(geometry.top).toBeCloseTo(Math.max(0, 76 - scrollY), 0);
expect(geometry.position).toBe('sticky');
expect(geometry.backgroundColor).toBe('rgb(17, 17, 19)');
measurements.push({ scrollY, ...geometry });
if (scrollY >= 76) {
expect(geometry.bottom).toBeCloseTo(geometry.viewportHeight, 0);
}
if (scrollY === 20 || scrollY === 76) {
await page.screenshot({ path: testInfo.outputPath(`admin-sidebar-scroll-${scrollY}.png`) });
}
}
await writeFile(testInfo.outputPath('admin-sidebar-scroll-geometry.json'), JSON.stringify(measurements, null, 2));
});
test('keeps mobile account actions on clean rows for users and administrators', async ({ browser }, testInfo) => {
const measureAccountActions = async (page: Page) => {
const actions = page.locator('.account-actions');
await expect(actions).toBeVisible();
return actions.evaluate((element) => {
const rect = (target: Element) => {
const value = target.getBoundingClientRect();
return {
top: value.top,
right: value.right,
bottom: value.bottom,
left: value.left,
width: value.width,
height: value.height,
};
};
const items = Array.from(element.children).map((child) => ({
...rect(child),
clientWidth: (child as HTMLElement).clientWidth,
scrollWidth: (child as HTMLElement).scrollWidth,
whiteSpace: getComputedStyle(child).whiteSpace,
}));
return {
container: rect(element),
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
items,
};
});
};
const userContext = await browser.newContext({ viewport: { width: 500, height: 900 } });
const userPage = await userContext.newPage();
await installGatewayFixture(userPage, []);
await userPage.goto('lobby');
const userGeometry = await measureAccountActions(userPage);
expect(userGeometry.documentWidth).toBe(userGeometry.viewportWidth);
expect(userGeometry.items).toHaveLength(2);
expect(userGeometry.items[0]?.top).toBeCloseTo(userGeometry.items[1]?.top ?? 0, 0);
expect(userGeometry.items[0]?.bottom).toBeCloseTo(userGeometry.items[1]?.bottom ?? 0, 0);
expect(userGeometry.items[0]?.width).toBeCloseTo(userGeometry.items[1]?.width ?? 0, 0);
expect(userGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
await userPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-user-500px.png'), fullPage: true });
await userContext.close();
const adminContext = await browser.newContext({ viewport: { width: 500, height: 900 } });
const adminPage = await adminContext.newPage();
await installGatewayFixture(adminPage, ['superuser']);
await adminPage.goto('lobby');
const adminGeometry = await measureAccountActions(adminPage);
expect(adminGeometry.documentWidth).toBe(adminGeometry.viewportWidth);
expect(adminGeometry.items).toHaveLength(3);
const [account, logout, admin] = adminGeometry.items;
expect(account?.top).toBeCloseTo(logout?.top ?? 0, 0);
expect(account?.bottom).toBeCloseTo(logout?.bottom ?? 0, 0);
expect(account?.width).toBeCloseTo(logout?.width ?? 0, 0);
expect(admin?.top).toBeCloseTo((logout?.bottom ?? 0) + 16, 0);
expect(admin?.left).toBeCloseTo(account?.left ?? 0, 0);
expect(admin?.right).toBeCloseTo(logout?.right ?? 0, 0);
expect(adminGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
expect(account?.whiteSpace).toBe('nowrap');
expect(admin?.whiteSpace).toBe('nowrap');
const adminLink = adminPage.getByRole('link', { name: '관리자 페이지' });
const baseBackground = await adminLink.evaluate((element) => getComputedStyle(element).backgroundColor);
await adminLink.hover();
await expect.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(
baseBackground
);
await adminLink.focus();
await expect(adminLink).toBeFocused();
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-500px.png'), fullPage: true });
await adminPage.setViewportSize({ width: 390, height: 844 });
const adminNarrowGeometry = await measureAccountActions(adminPage);
expect(adminNarrowGeometry.documentWidth).toBe(adminNarrowGeometry.viewportWidth);
expect(adminNarrowGeometry.items[0]?.top).toBeCloseTo(adminNarrowGeometry.items[1]?.top ?? 0, 0);
expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo(
(adminNarrowGeometry.items[1]?.bottom ?? 0) + 16,
0
);
expect(adminNarrowGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-390px.png'), fullPage: true });
await adminPage.setViewportSize({ width: 360, height: 800 });
const adminSmallGeometry = await measureAccountActions(adminPage);
expect(adminSmallGeometry.documentWidth).toBe(adminSmallGeometry.viewportWidth);
expect(adminSmallGeometry.items[1]?.top).toBeCloseTo((adminSmallGeometry.items[0]?.bottom ?? 0) + 16, 0);
expect(adminSmallGeometry.items[2]?.top).toBeCloseTo((adminSmallGeometry.items[1]?.bottom ?? 0) + 16, 0);
expect(adminSmallGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
await writeFile(
testInfo.outputPath('gateway-account-actions-geometry.json'),
JSON.stringify(
{
user: userGeometry,
admin: adminGeometry,
adminNarrow: adminNarrowGeometry,
adminSmall: adminSmallGeometry,
},
null,
2
)
);
await adminContext.close();
});
test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => {
await installGatewayFixture(page, ['superuser']);
await page.goto('admin/server-operations?operationId=legacy-operation');
await expect(page).toHaveURL(/\/gateway\/admin\/servers\?operationId=legacy-operation$/);
});
test('scoped administrators see the same navigation while ordinary users do not', async ({ browser }) => {
const scopedContext = await browser.newContext();
const scopedPage = await scopedContext.newPage();
await installGatewayFixture(scopedPage, ['admin.profiles.runtime:hwe:2']);
await scopedPage.goto('lobby');
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' });
await expect(scopedNavigation.getByRole('link', { name: '환상서버 [2]' })).toBeVisible();
await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
await scopedContext.close();
const userContext = await browser.newContext();
const userPage = await userContext.newPage();
await installGatewayFixture(userPage, []);
await userPage.goto('lobby');
await expect(userPage.getByRole('link', { name: '관리자 페이지' })).toHaveCount(0);
await userContext.close();
});