import { expect, test, type Page, type Route } from '@playwright/test'; import { writeFile } from 'node:fs/promises'; type OperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED'; type Operation = { id: string; profileName: string; type: 'RESET' | 'DEPLOY' | 'START' | 'STOP'; status: OperationStatus; sourceMode?: 'BRANCH' | 'COMMIT'; sourceRef?: string; resolvedCommitSha?: string; completedAt?: string; error?: string; payload: Record; requestedBy: string; createdAt: string; updatedAt: string; }; type FixtureState = { operations: Operation[]; gatewayOperations: Array<{ id: string; type: 'DEPLOY' | 'ROLLBACK'; status: OperationStatus; sourceMode?: 'BRANCH' | 'COMMIT'; sourceRef?: string; payload: Record; requestedBy: string; createdAt: string; updatedAt: string; }>; runtimeRunning: boolean; requestBodies: Array<{ operation: string; body: unknown }>; profileLogPollCount?: number; profileLogProgress?: boolean; profileLogsEmpty?: boolean; gatewayLogPollCount?: number; gatewayLogsEmpty?: boolean; capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>; profileListDelayMs?: number; profileNavigationDelayMs?: number; profileNavigationRequests?: number; profileNavigationResolved?: boolean; scenarioFailuresRemaining?: number; resetDefaults?: Record; updateMetaFails?: boolean; }; const profile = (runtimeRunning: boolean, resetDefaults?: Record) => ({ profileName: 'che:2', profile: 'che', scenario: '2', apiPort: 15003, status: runtimeRunning ? 'RUNNING' : 'STOPPED', buildStatus: 'SUCCEEDED', buildCommitSha: '0123456789abcdef0123456789abcdef01234567', buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef0123456789abcdef01234567', meta: resetDefaults ? { resetDefaults } : {}, createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z', activeOperation: null, runtimeActions: [], runtime: { profileName: 'che:2', frontendRunning: runtimeRunning, apiRunning: runtimeRunning, daemonRunning: runtimeRunning, auctionRunning: runtimeRunning, battleSimRunning: runtimeRunning, tournamentRunning: runtimeRunning, }, }); const scenarios = [ { id: 0, title: '【테스트】공백지', year: null, npcCount: 0, npcExCount: 0, npcNeutralCount: 0, nations: [], isCurrent: false, }, { id: 2, title: '【테스트】황건의 난', year: 184, npcCount: 42, npcExCount: 0, npcNeutralCount: 0, nations: [], isCurrent: true, }, { id: 5, title: '【테스트】군웅할거', year: 190, npcCount: 55, npcExCount: 0, npcNeutralCount: 0, nations: [], isCurrent: false, }, ]; 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 installFixture = async (page: Page, state: FixtureState) => { await page.addInitScript(() => { window.localStorage.setItem('sammo-session-token', 'playwright-admin-session'); }); await page.route('**/gateway/api/trpc/**', async (route) => { const names = operationNames(route); const body = route.request().postDataJSON() as unknown; if (names.includes('admin.profiles.listNavigation')) { expect(names).toEqual(['admin.profiles.listNavigation']); state.profileNavigationRequests = (state.profileNavigationRequests ?? 0) + 1; if (state.profileNavigationDelayMs) { await new Promise((resolve) => setTimeout(resolve, state.profileNavigationDelayMs)); } state.profileNavigationResolved = true; } if (names.includes('admin.profiles.list') && state.profileListDelayMs) { await new Promise((resolve) => setTimeout(resolve, state.profileListDelayMs)); } if (names.includes('admin.profiles.listScenarios') && (state.scenarioFailuresRemaining ?? 0) > 0) { state.scenarioFailuresRemaining = (state.scenarioFailuresRemaining ?? 0) - 1; await route.abort('failed'); return; } if (names.includes('admin.profiles.updateMeta') && state.updateMetaFails) { await route.abort('failed'); return; } if (names.includes('admin.operations.logs') && !state.profileLogProgress) { await new Promise((resolve) => setTimeout(resolve, 50)); } const results = names.map((name) => { if (route.request().method() === 'POST') { state.requestBodies.push({ operation: name, body }); } if (name === 'admin.profiles.list') { return response([profile(state.runtimeRunning, state.resetDefaults)]); } if (name === 'admin.profiles.listNavigation') { return response([ { profileName: 'che:2', profile: 'che', meta: { korName: '천하서버' }, }, ]); } if (name === 'admin.capabilities.list') { return response( state.capabilities ?? [ { 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.reset.schedule', scope: 'PROFILE', scopes: ['*'] }, { permission: 'admin.releases.manage', scope: 'GLOBAL', scopes: ['*'] }, ] ); } if (name === 'admin.operations.list') { return response(state.operations); } if (name === 'admin.operations.logs') { const operation = state.operations[0]; if (!operation) throw new Error('Profile operation fixture is missing'); state.profileLogPollCount = (state.profileLogPollCount ?? 0) + 1; if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) { return response({ operation, entries: [] }); } if (!state.profileLogProgress) { return response({ operation, entries: [] }); } if (state.profileLogsEmpty) { return response({ operation, entries: [] }); } const completed = state.profileLogPollCount > 1; const nextOperation = { ...operation, status: completed ? ('SUCCEEDED' as const) : ('RUNNING' as const), }; if (completed) state.operations[0] = nextOperation; return response({ operation: nextOperation, entries: completed ? [ { cursor: '2', operationId: operation.id, level: 'OUTPUT', phase: operation.type === 'RESET' ? 'seed' : 'build', message: operation.type === 'RESET' ? '시나리오 초기 데이터 생성을 완료했습니다.' : 'game-frontend build complete', createdAt: '2026-08-01T01:00:02.000Z', }, ] : [ { cursor: '1', operationId: operation.id, level: 'INFO', phase: 'build', message: `${operation.profileName} 구성 요소를 빌드합니다.`, createdAt: '2026-08-01T01:00:01.000Z', }, ], nextCursor: completed ? '2' : '1', }); } if (name === 'admin.releases.gatewayState') { return response({ id: 'gateway', activeCommitSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', activeWorkspace: '/srv/sammo/current', previousCommitSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', previousWorkspace: '/srv/sammo/previous', updatedAt: '2026-08-01T00:00:00.000Z', }); } if (name === 'admin.releases.list') { return response(state.gatewayOperations); } if (name === 'admin.releases.logs') { const releaseOperation = state.gatewayOperations[0]; if (!releaseOperation) throw new Error('Release operation fixture is missing'); state.gatewayLogPollCount = (state.gatewayLogPollCount ?? 0) + 1; if (state.gatewayLogsEmpty) { return response({ operation: releaseOperation, entries: [] }); } const completed = state.gatewayLogPollCount > 1; return response({ operation: { ...releaseOperation, status: completed ? 'SUCCEEDED' : 'RUNNING' }, entries: completed ? [ { cursor: '2', operationId: releaseOperation.id, level: 'OUTPUT', phase: 'build', message: 'gateway-frontend build complete', createdAt: '2026-08-01T02:00:02.000Z', }, ] : [ { cursor: '1', operationId: releaseOperation.id, level: 'INFO', phase: 'build', message: 'Gateway 구성 요소를 빌드합니다.', createdAt: '2026-08-01T02:00:01.000Z', }, ], nextCursor: completed ? '2' : '1', }); } if (name === 'admin.profiles.listScenarios') { expect(names).toEqual(['admin.profiles.listScenarios']); return response(scenarios); } if (name === 'admin.profiles.getResetDefaults') { return response({ source: state.resetDefaults ? 'PROFILE' : 'SYSTEM', defaults: state.resetDefaults ?? { turnTermMinutes: 60, sync: true, fiction: 1, extend: true, blockGeneralCreate: 0, npcMode: 0, showImgLevel: 3, tournamentTrig: true, joinMode: 'full', autorunUser: null, }, }); } if (name === 'admin.profiles.updateMeta') { return response(profile(state.runtimeRunning, state.resetDefaults)); } if (name === 'admin.operations.requestReset') { const operation: Operation = { id: '11111111-1111-4111-8111-111111111111', profileName: 'che:2', type: 'RESET', status: 'QUEUED', sourceMode: 'COMMIT', sourceRef: '0123456789abcdef0123456789abcdef01234567', payload: {}, requestedBy: 'admin', createdAt: '2026-07-25T02:00:00.000Z', updatedAt: '2026-07-25T02:00:00.000Z', }; state.operations = [operation]; return response(operation); } if (name === 'admin.operations.requestDeploy') { const operation: Operation = { id: '66666666-6666-4666-8666-666666666666', profileName: 'che:2', type: 'DEPLOY', status: 'QUEUED', sourceMode: 'BRANCH', sourceRef: 'main', payload: {}, requestedBy: 'admin', createdAt: '2026-08-01T01:00:00.000Z', updatedAt: '2026-08-01T01:00:00.000Z', }; state.operations = [operation]; return response(operation); } if (name === 'admin.releases.requestGatewayDeploy' || name === 'admin.releases.requestGatewayRollback') { const releaseOperation = { id: '77777777-7777-4777-8777-777777777777', type: name.endsWith('Rollback') ? ('ROLLBACK' as const) : ('DEPLOY' as const), status: 'QUEUED' as const, sourceMode: 'BRANCH' as const, sourceRef: 'main', payload: {}, requestedBy: 'admin', createdAt: '2026-08-01T02:00:00.000Z', updatedAt: '2026-08-01T02:00:00.000Z', }; state.gatewayOperations = [releaseOperation]; return response(releaseOperation); } if (name === 'admin.operations.requestRuntime') { const serialized = JSON.stringify(body); const type = serialized.includes('"STOP"') ? 'STOP' : 'START'; state.runtimeRunning = type === 'START'; const operation: Operation = { id: type === 'START' ? '22222222-2222-4222-8222-222222222222' : '33333333-3333-4333-8333-333333333333', profileName: 'che:2', type, status: 'SUCCEEDED', payload: {}, requestedBy: 'admin', createdAt: '2026-07-25T03:00:00.000Z', updatedAt: '2026-07-25T03:00:00.000Z', }; state.operations = [operation]; return response(operation); } if (name === 'admin.operations.retry') { const operation: Operation = { id: '44444444-4444-4444-8444-444444444444', profileName: 'che:2', type: 'RESET', status: 'QUEUED', sourceMode: 'COMMIT', sourceRef: 'fedcba9876543210fedcba9876543210fedcba98', payload: { installOperationId: 'failed-generation' }, requestedBy: 'admin', createdAt: '2026-07-25T04:00:00.000Z', updatedAt: '2026-07-25T04:00:00.000Z', }; state.operations = [operation, ...state.operations]; return response(operation); } throw new Error(`Unhandled tRPC operation: ${name}`); }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify( new URL(route.request().url()).searchParams.get('batch') === '1' ? results : results[0] ), }); }); }; test('separates branch and commit semantics and submits a reset from the dedicated page', async ({ page, }, testInfo) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: false, requestBodies: [], profileLogProgress: true, }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/servers/che%3A2/scenario'); await expect(page.getByTestId('server-operations-page')).toBeVisible(); await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/); await expect(page.getByTestId('source-current')).toBeChecked(); await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋'); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); await expect(page.getByTestId('request-reset')).toBeEnabled(); await expect(page.getByTestId('scenario-select').locator('option:checked')).toContainText('현재 시나리오'); const catalogGeometry = await page.getByTestId('scenario-select').evaluate((select) => { const scenarioSelect = select as HTMLSelectElement; const rect = select.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, optionCount: scenarioSelect.options.length, value: scenarioSelect.value, }; }); expect(catalogGeometry.optionCount).toBe(3); expect(catalogGeometry.value).toBe('2'); expect(catalogGeometry.width).toBeGreaterThan(300); await page.screenshot({ path: testInfo.outputPath('current-scenario-catalog.png'), fullPage: true }); await page.getByTestId('scenario-select').selectOption('0'); await expect(page.getByTestId('request-reset')).toBeEnabled(); await expect(page.getByTestId('server-profile-tabs')).toBeVisible(); await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveAttribute( 'aria-current', 'page' ); await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); const desktopGeometry = await page .getByTestId('server-operations-page') .locator('section') .first() .evaluate((section) => { const children = Array.from(section.children).map((child) => { const rect = child.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }); return children; }); expect(desktopGeometry).toHaveLength(1); expect(desktopGeometry[0]!.width).toBeGreaterThan(800); await page.getByTestId('source-commit').check(); const sourceInput = page.getByTestId('source-ref'); await sourceInput.focus(); const focusedInputStyle = await sourceInput.evaluate((element) => { const style = getComputedStyle(element); return { borderColor: style.borderColor, backgroundColor: style.backgroundColor, color: style.color, fontSize: style.fontSize, lineHeight: style.lineHeight, outlineStyle: style.outlineStyle, }; }); await writeFile( testInfo.outputPath('layout-metrics.json'), JSON.stringify({ catalogGeometry, desktopGeometry, focusedInputStyle }, null, 2) ); await page.screenshot({ path: testInfo.outputPath('desktop-operations.png'), fullPage: true }); await expect(page.getByTestId('source-help')).toContainText('전체 SHA로 고정'); await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567'); await page.getByTestId('load-scenarios').click(); await page.getByTestId('scenario-select').selectOption('5'); await page.getByTestId('request-reset').hover(); await page.getByTestId('request-reset').click(); await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByTestId('operations-table')).toContainText('RESET'); await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => { const columnWidths = Array.from(table.querySelectorAll('thead th')).map( (heading) => heading.getBoundingClientRect().width ); const rowHeight = table.querySelector('tbody tr')?.getBoundingClientRect().height ?? 0; return { columnWidths, rowHeight, tableWidth: table.getBoundingClientRect().width, scrollerWidth: table.parentElement?.getBoundingClientRect().width ?? 0, tableLayout: getComputedStyle(table).tableLayout, }; }); const sourceRefGeometry = await page.getByTestId('operation-source-ref').evaluate((element) => { const style = getComputedStyle(element); return { title: element.getAttribute('title'), overflow: style.overflow, textOverflow: style.textOverflow, whiteSpace: style.whiteSpace, }; }); expect(operationTableGeometry.tableLayout).toBe('fixed'); expect(operationTableGeometry.tableWidth).toBeGreaterThanOrEqual(1_300); expect(operationTableGeometry.tableWidth).toBeGreaterThan(operationTableGeometry.scrollerWidth); expect(operationTableGeometry.columnWidths[0]).toBeGreaterThanOrEqual(159); expect(operationTableGeometry.columnWidths[1]).toBeGreaterThanOrEqual(263); expect(operationTableGeometry.columnWidths[5]).toBeLessThanOrEqual(113); expect(operationTableGeometry.columnWidths[7]).toBeGreaterThanOrEqual(175); expect(operationTableGeometry.columnWidths[1]).toBeGreaterThan(operationTableGeometry.columnWidths[5]! * 2); expect(operationTableGeometry.rowHeight).toBeLessThanOrEqual(50); expect(sourceRefGeometry).toEqual({ title: '0123456789abcdef0123456789abcdef01234567', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }); await writeFile( testInfo.outputPath('operation-table-metrics.json'), JSON.stringify({ operationTableGeometry, sourceRefGeometry }, null, 2) ); const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset'); expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"'); expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567'); expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5'); await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); const mobileGeometry = await page .getByTestId('server-operations-page') .locator('section') .first() .evaluate((section) => { const children = Array.from(section.children).map((child) => { const rect = child.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width }; }); return children; }); expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390); const mobileTabs = await page .getByTestId('server-profile-tabs') .locator('a') .evaluateAll((links) => links.map((link) => { const rect = link.getBoundingClientRect(); return { top: rect.top, width: rect.width, height: rect.height }; }) ); expect(mobileTabs).toHaveLength(3); expect(mobileTabs[1]!.top).toBeGreaterThan(mobileTabs[0]!.top); expect(mobileTabs.every((tab) => tab.height >= 44)).toBe(true); const mobileOperationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => { const scroller = table.parentElement!; const scrollerRect = scroller.getBoundingClientRect(); return { tableWidth: table.getBoundingClientRect().width, scrollerX: scrollerRect.x, scrollerWidth: scrollerRect.width, scrollerScrollWidth: scroller.scrollWidth, viewportWidth: document.documentElement.clientWidth, documentScrollWidth: document.documentElement.scrollWidth, }; }); expect(mobileOperationTableGeometry.tableWidth).toBeGreaterThanOrEqual(1_300); expect(mobileOperationTableGeometry.scrollerScrollWidth).toBeGreaterThan( mobileOperationTableGeometry.scrollerWidth ); expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0); expect( mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth ).toBeLessThanOrEqual(mobileOperationTableGeometry.viewportWidth); expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual( mobileOperationTableGeometry.viewportWidth ); await writeFile( testInfo.outputPath('operation-table-mobile-metrics.json'), JSON.stringify(mobileOperationTableGeometry, null, 2) ); await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true }); }); test('separates DB-preserving profile deployment from DB reset', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [], profileLogProgress: true, }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/servers/che%3A2/version'); await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible(); await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute( 'aria-current', 'page' ); await page.getByTestId('request-deploy').click(); await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByTestId('operations-table')).toContainText('DEPLOY'); await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false); }); test('loads server metadata defaults into the reset form and submits them', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [], resetDefaults: { turnTermMinutes: 20, sync: false, fiction: 0, extend: false, blockGeneralCreate: 2, npcMode: 1, showImgLevel: 1, tournamentTrig: false, joinMode: 'onlyRandom', autorunUser: { limitMinutes: 720, options: ['develop', 'train'] }, }, }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/servers/che%3A2/scenario'); await expect(page.getByTestId('reset-turn-term')).toHaveValue('20'); await page.getByText('고급 시나리오 옵션').click(); await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타'); await expect(page.getByTestId('reset-npc-mode')).toHaveValue('1'); await page.getByTestId('request-reset').click(); await expect .poll(() => state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset')) .toBeTruthy(); const request = JSON.stringify( state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset')?.body ); expect(request).toContain('"turnTermMinutes":20'); expect(request).toContain('"npcMode":1'); expect(request).toContain('"joinMode":"onlyRandom"'); expect(request).toContain('"limitMinutes":720'); expect(request).toContain('"options":["develop","train"]'); }); test('edits server reset defaults through profile metadata settings', async ({ page }, testInfo) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; await installFixture(page, state); await page.goto('admin/servers/che%3A2'); await page.getByText('서버 리셋 기본 옵션').click(); await page.getByTestId('meta-reset-turn-term').selectOption('10'); await page.getByTestId('meta-reset-npc-mode').selectOption('2'); await page.getByRole('button', { name: '메타 저장' }).click(); const validationToast = page.getByTestId('action-toast').filter({ hasText: '변경 사유를 입력하세요.' }); await expect(validationToast).toHaveAttribute('data-toast-kind', 'error'); await expect(validationToast).toHaveAttribute('role', 'alert'); await page.getByPlaceholder('변경 사유 (필수)').fill('set reset defaults'); await page.getByRole('button', { name: '메타 저장' }).click(); await expect(page.getByText('메타 저장 완료').first()).toBeVisible(); const successToast = page.getByTestId('action-toast').filter({ hasText: '메타 저장 완료' }); await expect(successToast).toHaveAttribute('data-toast-kind', 'success'); await expect(successToast).toHaveAttribute('role', 'status'); const toastGeometry = await successToast.evaluate((element) => { const rect = element.getBoundingClientRect(); const viewport = element.parentElement?.parentElement; return { right: Math.round(window.innerWidth - rect.right), width: Math.round(rect.width), viewportPosition: viewport ? getComputedStyle(viewport).position : '', }; }); expect(toastGeometry.right).toBeGreaterThanOrEqual(0); expect(toastGeometry.width).toBeGreaterThan(250); expect(toastGeometry.viewportPosition).toBe('fixed'); await page.screenshot({ path: testInfo.outputPath('meta-save-toast-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); const mobileToastGeometry = await successToast.evaluate((element) => { const rect = element.getBoundingClientRect(); return { left: Math.round(rect.left), right: Math.round(window.innerWidth - rect.right), bottom: Math.round(window.innerHeight - rect.bottom), }; }); expect(mobileToastGeometry.left).toBeGreaterThanOrEqual(0); expect(mobileToastGeometry.right).toBeGreaterThanOrEqual(0); expect(mobileToastGeometry.bottom).toBeGreaterThanOrEqual(0); expect(mobileToastGeometry.bottom).toBeLessThanOrEqual(20); await page.screenshot({ path: testInfo.outputPath('meta-save-toast-mobile.png'), fullPage: true }); const request = JSON.stringify( state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body ); expect(request).toContain('"resetDefaults"'); expect(request).toContain('"turnTermMinutes":10'); expect(request).toContain('"npcMode":2'); }); test('shows a dismissible error toast when profile metadata persistence fails', async ({ page }, testInfo) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [], updateMetaFails: true, }; await installFixture(page, state); await page.goto('admin/servers/che%3A2'); await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error'); await page.getByRole('button', { name: '메타 저장' }).click(); const errorToast = page.getByTestId('action-toast').filter({ hasText: '메타 저장 실패' }); await expect(errorToast).toBeVisible(); await expect(errorToast).toHaveAttribute('data-toast-kind', 'error'); await page.screenshot({ path: testInfo.outputPath('meta-save-toast-error.png'), fullPage: true }); await errorToast.getByRole('button', { name: '알림 닫기' }).click(); await expect(errorToast).toHaveCount(0); }); test('renders the fixed-profile version form without waiting for the server list', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [], profileNavigationDelayMs: 3000, profileNavigationResolved: false, }; await installFixture(page, state); await page.goto('admin/servers/che%3A2/version'); await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 }); expect(state.profileNavigationResolved).toBe(false); await expect.poll(() => state.profileNavigationResolved).toBe(true); expect(state.profileNavigationRequests).toBe(1); }); test('recovers the current-version scenario catalog after the initial request fails', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [], scenarioFailuresRemaining: 1, }; await installFixture(page, state); await page.goto('admin/servers/che%3A2/scenario'); await expect(page.getByTestId('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.'); await expect(page.getByTestId('request-reset')).toBeDisabled(); await page.getByTestId('load-scenarios').click(); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); await expect(page.getByTestId('request-reset')).toBeEnabled(); }); test('renders the server navigation before the detailed runtime profile request resolves', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [], profileListDelayMs: 1500, }; await installFixture(page, state); await page.goto('admin/servers/che%3A2'); const navigation = page.getByRole('navigation', { name: '관리자 메뉴' }); await expect(navigation.getByRole('link', { name: '천하서버 (che:2)' })).toBeVisible({ timeout: 900 }); await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 }); expect(state.profileNavigationRequests).toBe(1); }); test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [], capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:2'] }], }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/servers/che%3A2/scenario'); await expect(page.getByTestId('source-current')).toBeChecked(); await expect(page.getByTestId('source-branch')).toHaveCount(0); await expect(page.getByTestId('source-commit')).toHaveCount(0); await expect(page.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0); await page.getByTestId('request-reset').click(); await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); await expect .poll(() => state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')) .toBe(true); const request = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset'); expect(JSON.stringify(request?.body)).toContain('"sourceMode":"CURRENT"'); expect(JSON.stringify(request?.body)).not.toContain('"sourceRef"'); }); test('controls gateway deployment and rollback through the external controller queue', async ({ page }, testInfo) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/releases'); const panel = page.getByTestId('gateway-release-panel'); await expect(panel).toBeVisible(); await expect(panel).toContainText('aaaaaaaaaaaa'); await expect(panel).toContainText('bbbbbbbbbbbb'); await page.getByTestId('gateway-source-ref').fill('release/2026-08'); await page.getByTestId('request-gateway-deploy').click(); await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/).first()).toBeVisible(); await expect(page.getByTestId('gateway-release-table')).toContainText('DEPLOY'); await expect(page.getByTestId('gateway-release-log-panel')).toBeVisible(); await expect(page.getByTestId('gateway-release-log')).toContainText('Gateway 구성 요소를 빌드합니다.'); await expect(page.getByTestId('gateway-release-log')).toContainText('gateway-frontend build complete'); await expect(page.getByTestId('gateway-release-log-status')).toContainText('SUCCEEDED'); expect(state.gatewayLogPollCount).toBeGreaterThanOrEqual(2); expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayDeploy')).toBe(true); await page.screenshot({ path: testInfo.outputPath('gateway-release-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); const mobileLogGeometry = await page.getByTestId('gateway-release-log-panel').evaluate((element) => { const rect = element.getBoundingClientRect(); return { x: rect.x, width: rect.width, viewportWidth: document.documentElement.clientWidth }; }); expect(mobileLogGeometry.x).toBeGreaterThanOrEqual(0); expect(mobileLogGeometry.x + mobileLogGeometry.width).toBeLessThanOrEqual(mobileLogGeometry.viewportWidth); await page.screenshot({ path: testInfo.outputPath('gateway-release-mobile.png'), fullPage: true }); state.gatewayOperations = []; await page.getByTestId('refresh-operations').click(); await page.getByTestId('request-gateway-rollback').click(); await expect(page.getByText('Gateway rollback 작업을 등록했습니다.').first()).toBeVisible(); expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayRollback')).toBe(true); }); test('explains terminal releases created before controller progress logging', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [ { id: '99999999-9999-4999-8999-999999999999', type: 'DEPLOY', status: 'SUCCEEDED', sourceMode: 'COMMIT', sourceRef: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', payload: {}, requestedBy: 'admin', createdAt: '2026-08-01T02:00:00.000Z', updatedAt: '2026-08-01T02:02:00.000Z', }, ], gatewayLogsEmpty: true, runtimeRunning: true, requestBodies: [], }; await installFixture(page, state); await page.goto('admin/releases'); await expect(page.getByTestId('gateway-release-log')).toContainText( '로그 지원 controller 적용 전 작업일 수 있습니다.' ); await expect(page.getByTestId('gateway-release-log')).not.toContainText('controller 로그를 기다리고 있습니다'); }); test('renders a failed reset, retries it as a new operation, and reaches success', async ({ page }, testInfo) => { const longError = '선택한 커밋의 프로필 프로세스를 시작하지 못했습니다. 실패 원인을 확인한 뒤 동일 generation으로 재시도해 주세요.'; const state: FixtureState = { operations: [ { id: '55555555-5555-4555-8555-555555555555', profileName: 'che:2', type: 'RESET', status: 'FAILED', sourceMode: 'COMMIT', sourceRef: 'fedcba9876543210fedcba9876543210fedcba98', resolvedCommitSha: 'fedcba9876543210fedcba9876543210fedcba98', payload: { installOperationId: 'failed-generation' }, requestedBy: 'admin', completedAt: '2026-07-25T03:30:00.000Z', error: longError, createdAt: '2026-07-25T03:00:00.000Z', updatedAt: '2026-07-25T03:30:00.000Z', }, ], gatewayOperations: [], runtimeRunning: false, requestBodies: [], }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/servers/che%3A2/scenario'); await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible(); await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); const failure = page.getByTestId('operations-table').getByText(longError); await expect(failure).toBeVisible(); expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)'); await page.getByRole('button', { name: '재시도' }).click(); await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible(); await expect(page.getByTestId('operations-table').getByText('QUEUED', { exact: true })).toBeVisible(); await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2); state.operations[0] = { ...state.operations[0]!, status: 'SUCCEEDED', resolvedCommitSha: 'fedcba9876543210fedcba9876543210fedcba98', completedAt: '2026-07-25T04:05:00.000Z', updatedAt: '2026-07-25T04:05:00.000Z', }; state.runtimeRunning = true; await page.getByTestId('refresh-operations').click(); await expect(page.getByTestId('operations-table').getByText('SUCCEEDED', { exact: true })).toBeVisible(); await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); const tableGeometry = await page.getByTestId('operations-table').evaluate((table) => { const tableRect = table.getBoundingClientRect(); const scrollerRect = table.parentElement!.getBoundingClientRect(); return { tableWidth: tableRect.width, scrollerWidth: scrollerRect.width }; }); expect(tableGeometry.tableWidth).toBeGreaterThan(tableGeometry.scrollerWidth); await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-mobile.png'), fullPage: true }); });