import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); const errorResponse = (path: string, message: string) => ({ error: { message, code: -32603, data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path, }, }, }); 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 => { await route.fulfill({ status: 200, contentType: 'application/json', headers: { 'access-control-allow-origin': '*', }, body: JSON.stringify(results), }); }; const profileInputAt = (body: string, index: number): string | null => { try { const parsed = JSON.parse(body) as Record; const input = parsed[String(index)]; const profile = input?.profile ?? input?.json?.profile; return typeof profile === 'string' ? profile : null; } catch { return null; } }; type FixtureOptions = { failHweAdjustOnce?: boolean; delayHweAdjust?: boolean; }; const activeProfiles = [ { profileName: 'che:903', profile: 'che', apiPort: 15003, korName: 'CHE 서버', }, { profileName: 'hwe:903', profile: 'hwe', apiPort: 15015, korName: '훼', }, ]; const installFixture = async (page: Page, options: FixtureOptions = {}) => { let deleteIconCount = 0; let preferredIconCount = 0; let retireIconCount = 0; let hweAdjustCount = 0; const operations = new Map([ ['che:903', []], ['hwe:903', []], ]); await page.addInitScript(() => { window.localStorage.setItem('sammo-session-token', 'account-session'); }); await page.route('**/gateway/api/trpc/**', async (route) => { const body = route.request().postData() ?? ''; const results = operationNames(route).map((operation, index) => { if (operation === 'account.get') { return response({ id: 'account-user', username: 'account-user', displayName: '계정 사용자', roles: ['user'], oauthType: 'NONE', createdAt: '2026-07-30T00:00:00.000Z', iconUrl: '/gateway/api/user-icons/old.png', icons: [ { id: '3f804277-584f-4f44-b39c-9ecf40d1ed31', picture: 'old.png', imageServer: 1, createdAt: '2026-07-30T00:00:00.000Z', retiredAt: null, url: '/gateway/api/user-icons/old.png', }, { id: '9bc328b0-3fc8-44ec-a845-287e438e8edf', picture: 'second.png', imageServer: 1, createdAt: '2026-07-31T00:00:00.000Z', retiredAt: null, url: '/gateway/api/user-icons/second.png', }, ], preferredPicture: 'old.png', maxActiveIcons: 5, nextUploadAt: null, nextRetireAt: null, thirdPartyUse: false, deleteAfter: null, }); } if (operation === 'account.changeIcon') { return response({ ok: true, iconUrl: '/gateway/api/user-icons/new.png', revision: '2026-07-31T09:00:00.001Z', profiles: activeProfiles, flushPublished: true, }); } if (operation === 'account.setPreferredIcon') { preferredIconCount += 1; return response({ ok: true, revision: '2026-08-01T00:00:00.001Z', flushPublished: true }); } if (operation === 'account.retireIcon') { retireIconCount += 1; return response({ ok: true, revision: '2026-08-01T00:00:00.002Z', preferredChanged: false, iconUrl: '/gateway/api/user-icons/old.png', flushPublished: true, }); } if (operation === 'account.deleteIcon') { deleteIconCount += 1; return response({ ok: true, iconUrl: null, revision: '2026-07-31T09:00:00.002Z', profiles: [activeProfiles[1]], flushPublished: true, }); } if (operation === 'account.prepareIconSync') { return response({ iconUrl: '/gateway/api/user-icons/new.png', projection: { revision: '2026-07-31T09:00:00.001Z', picture: 'new.png', imageServer: 1, }, profiles: activeProfiles, }); } if (operation === 'auth.issueGameSession') { const profileName = profileInputAt(body, index); expect(profileName === 'che:903' || profileName === 'hwe:903').toBe(true); if (profileName !== 'che:903' && profileName !== 'hwe:903') { throw new Error('issueGameSession profile input was not encoded in the request.'); } operations.get(profileName)?.push('issueGameSession'); return response({ profile: profileName, gameToken: `gateway-token-${profileName}`, expiresAt: '2026-07-31T01:00:00.000Z', }); } throw new Error(`Unhandled gateway tRPC operation: ${operation}`); }); await fulfillTrpc(route, results); }); const installGameRoute = async (profileName: 'che:903' | 'hwe:903'): Promise => { const handle = async (route: Route): Promise => { expect(new URL(route.request().url()).pathname).toBe( `/${profileName.split(':')[0]}/api/trpc/${operationNames(route).join(',')}` ); const results = []; for (const operation of operationNames(route)) { if (operation === 'auth.exchangeGatewayToken') { operations.get(profileName)?.push('exchangeGatewayToken'); results.push( response({ accessToken: `access-token-${profileName}`, profile: profileName, expiresAt: '2026-07-31T01:00:00.000Z', }) ); continue; } if (operation === 'general.adjustIcon') { operations.get(profileName)?.push('adjustIcon'); if (profileName === 'hwe:903') { hweAdjustCount += 1; if (options.delayHweAdjust) { await new Promise((resolve) => setTimeout(resolve, 200)); } if (options.failHweAdjustOnce && hweAdjustCount === 1) { results.push(errorResponse(operation, 'HWE 아이콘 적용 실패')); continue; } } expect(route.request().headers().authorization).toBe(`Bearer access-token-${profileName}`); results.push(response({ generalId: 101, updated: true })); continue; } throw new Error(`Unhandled ${profileName} tRPC operation: ${operation}`); } await fulfillTrpc(route, results); }; await page.route(`**/${profileName.split(':')[0]}/api/trpc/**`, handle); }; await installGameRoute('che:903'); await installGameRoute('hwe:903'); return { operations, deleteIconCount: () => deleteIconCount, preferredIconCount: () => preferredIconCount, retireIconCount: () => retireIconCount, }; }; test('chooses a preferred library icon and retires an icon only after confirmation', async ({ page }) => { const fixture = await installFixture(page); await page.goto('account'); await expect(page.locator('.account-icon-card')).toHaveCount(2); await expect(page.getByText('2 / 5개')).toBeVisible(); await page.getByRole('button', { name: '대표로 설정' }).click(); await expect.poll(fixture.preferredIconCount).toBe(1); page.once('dialog', async (dialog) => { expect(dialog.message()).toContain('과거 기록의 이미지는 보존됩니다'); await dialog.accept(); }); await page.getByRole('button', { name: '목록에서 내리기' }).first().click(); await expect.poll(fixture.retireIconCount).toBe(1); }); const uploadIcon = async (page: Page): Promise => { await page.locator('input[type="file"]').setInputFiles({ name: 'new-icon.png', mimeType: 'image/png', buffer: Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64' ), }); await page.getByRole('button', { name: '아이콘 변경' }).click(); }; test('selects every returned server and applies only checked servers in issue-exchange-adjust order', async ({ page, }) => { const fixture = await installFixture(page, { delayHweAdjust: true }); await page.goto('account'); await uploadIcon(page); const modal = page.getByTestId('icon-server-modal'); await expect(modal).toBeVisible(); await expect(modal).toContainText('완료되었습니다.'); await expect(page.getByTestId('icon-server-option-che:903')).toBeChecked(); await expect(page.getByTestId('icon-server-option-hwe:903')).toBeChecked(); await expect(page.getByTestId('icon-server-option-stopped:903')).toHaveCount(0); await expect(page.locator('.icon-server-dialog')).toBeFocused(); await page.getByTestId('icon-server-apply').focus(); await page.keyboard.press('Tab'); await expect(page.getByTestId('icon-server-close')).toBeFocused(); await page.keyboard.press('Shift+Tab'); await expect(page.getByTestId('icon-server-apply')).toBeFocused(); await page.getByTestId('icon-server-option-che:903').uncheck(); await page.getByTestId('icon-server-apply').click(); await expect(page.getByTestId('icon-server-apply')).toBeDisabled(); await expect(page.getByTestId('icon-server-close')).toBeDisabled(); await expect .poll(() => page.getByTestId('icon-server-apply').evaluate((element) => ({ opacity: getComputedStyle(element).opacity, transitionDuration: getComputedStyle(element).transitionDuration, })) ) .toEqual({ opacity: '0.65', transitionDuration: '0.15s, 0.15s, 0.15s, 0.15s', }); await expect .poll(() => page.evaluate(() => { const dialog = document.querySelector('.icon-server-dialog'); return Boolean(dialog && dialog.contains(document.activeElement)); }) ) .toBe(true); await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용 중'); await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용됨'); expect(fixture.operations.get('che:903')).toEqual([]); expect(fixture.operations.get('hwe:903')).toEqual(['issueGameSession', 'exchangeGatewayToken', 'adjustIcon']); await page.keyboard.press('Escape'); await expect(modal).toBeVisible(); await page.getByTestId('icon-server-close').click(); await expect(modal).toBeHidden(); await expect(page.getByRole('button', { name: '아이콘 변경' })).toBeFocused(); }); test('keeps per-server failures visible and retries only failed servers', async ({ page }) => { const fixture = await installFixture(page, { failHweAdjustOnce: true }); await page.goto('account'); await uploadIcon(page); await page.getByTestId('icon-server-apply').click(); await expect(page.getByTestId('icon-server-result-che:903')).toContainText('적용됨'); await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('HWE 아이콘 적용 실패'); await expect(page.getByTestId('icon-server-retry')).toBeVisible(); await page.getByTestId('icon-server-retry').click(); await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용됨'); await expect(page.getByTestId('icon-server-retry')).toHaveCount(0); expect(fixture.operations.get('che:903')).toEqual(['issueGameSession', 'exchangeGatewayToken', 'adjustIcon']); expect(fixture.operations.get('hwe:903')).toEqual([ 'issueGameSession', 'exchangeGatewayToken', 'adjustIcon', 'issueGameSession', 'exchangeGatewayToken', 'adjustIcon', ]); }); test('uses the Ref delete confirmation and opens the modal only after acceptance', async ({ page }, testInfo) => { const fixture = await installFixture(page); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('account'); page.once('dialog', async (dialog) => { expect(dialog.message()).toBe('아이콘을 제거할까요?'); await dialog.dismiss(); }); await page.getByRole('button', { name: '아이콘 제거' }).click(); await expect(page.getByTestId('icon-server-modal')).toHaveCount(0); expect(fixture.deleteIconCount()).toBe(0); page.once('dialog', async (dialog) => { expect(dialog.message()).toBe('아이콘을 제거할까요?'); await dialog.accept(); }); await page.getByRole('button', { name: '아이콘 제거' }).click(); await expect(page.getByTestId('icon-server-modal')).toBeVisible(); await page.evaluate(() => document.fonts.ready); await expect(page.getByTestId('icon-server-option-hwe:903')).toBeChecked(); await expect(page.getByTestId('icon-server-option-che:903')).toHaveCount(0); expect(fixture.deleteIconCount()).toBe(1); await page.locator('.icon-server-dialog').screenshot({ path: testInfo.outputPath('core-icon-modal-desktop.png'), animations: 'disabled', }); const geometry = await page.evaluate(() => { const rect = (selector: string) => { const value = document.querySelector(selector)!.getBoundingClientRect(); return { x: value.x, y: value.y, width: value.width, height: value.height }; }; const dialogStyle = getComputedStyle(document.querySelector('.icon-server-dialog')!); const titleStyle = getComputedStyle(document.querySelector('.icon-server-header h2')!); const closeStyle = getComputedStyle(document.querySelector('[data-testid="icon-server-close"]')!); const secondaryStyle = getComputedStyle(document.querySelector('.icon-server-footer .secondary')!); const applyStyle = getComputedStyle(document.querySelector('[data-testid="icon-server-apply"]')!); const backdropStyle = getComputedStyle(document.querySelector('.icon-server-backdrop')!); const motionStyle = getComputedStyle(document.querySelector('.icon-server-dialog')!); return { dialog: rect('.icon-server-dialog'), header: rect('.icon-server-header'), title: rect('.icon-server-header h2'), body: rect('.icon-server-body'), footer: rect('.icon-server-footer'), close: rect('[data-testid="icon-server-close"]'), secondary: rect('.icon-server-footer .secondary'), apply: rect('[data-testid="icon-server-apply"]'), dialogStyle: { color: dialogStyle.color, backgroundColor: dialogStyle.backgroundColor, borderColor: dialogStyle.borderColor, borderRadius: dialogStyle.borderRadius, boxShadow: dialogStyle.boxShadow, fontFamily: dialogStyle.fontFamily, fontSize: dialogStyle.fontSize, lineHeight: dialogStyle.lineHeight, }, titleStyle: { fontSize: titleStyle.fontSize, fontWeight: titleStyle.fontWeight, lineHeight: titleStyle.lineHeight, }, closeStyle: { color: closeStyle.color, backgroundColor: closeStyle.backgroundColor, borderColor: closeStyle.borderColor, borderRadius: closeStyle.borderRadius, fontSize: closeStyle.fontSize, fontWeight: closeStyle.fontWeight, lineHeight: closeStyle.lineHeight, padding: closeStyle.padding, opacity: closeStyle.opacity, }, secondaryStyle: { color: secondaryStyle.color, backgroundColor: secondaryStyle.backgroundColor, borderColor: secondaryStyle.borderColor, borderRadius: secondaryStyle.borderRadius, fontSize: secondaryStyle.fontSize, fontWeight: secondaryStyle.fontWeight, lineHeight: secondaryStyle.lineHeight, padding: secondaryStyle.padding, }, applyStyle: { color: applyStyle.color, backgroundColor: applyStyle.backgroundColor, borderColor: applyStyle.borderColor, borderRadius: applyStyle.borderRadius, fontSize: applyStyle.fontSize, fontWeight: applyStyle.fontWeight, lineHeight: applyStyle.lineHeight, padding: applyStyle.padding, transitionDuration: applyStyle.transitionDuration, transitionProperty: applyStyle.transitionProperty, }, backdropStyle: { transitionDuration: backdropStyle.transitionDuration, transitionProperty: backdropStyle.transitionProperty, }, motionStyle: { transitionDuration: motionStyle.transitionDuration, transitionProperty: motionStyle.transitionProperty, transitionTimingFunction: motionStyle.transitionTimingFunction, }, }; }); expect(geometry.dialog).toEqual({ x: 470, y: 28, width: 500, height: 224 }); expect(geometry.header).toEqual({ x: 471, y: 29, width: 498, height: 93 }); expect(geometry.title).toEqual({ x: 487, y: 45, width: 297, height: 60 }); expect(geometry.body).toEqual({ x: 471, y: 122, width: 498, height: 56 }); expect(geometry.footer).toEqual({ x: 471, y: 178, width: 498, height: 73 }); expect(geometry.close).toEqual({ x: 927, y: 60, width: 26, height: 30 }); expect(geometry.secondary).toEqual({ x: 805, y: 195, width: 54, height: 40 }); expect(geometry.apply).toEqual({ x: 867, y: 195, width: 86, height: 40 }); expect(geometry.dialogStyle).toMatchObject({ color: 'rgb(255, 255, 255)', backgroundColor: 'rgb(48, 48, 48)', borderColor: 'rgb(68, 68, 68)', borderRadius: '8px', boxShadow: 'none', fontSize: '16px', lineHeight: '24px', }); expect(geometry.dialogStyle.fontFamily).toContain('Pretendard'); expect(geometry.titleStyle).toEqual({ fontSize: '20px', fontWeight: '500', lineHeight: '30px' }); expect(geometry.closeStyle).toEqual({ color: 'rgb(255, 255, 255)', backgroundColor: 'rgb(107, 107, 107)', borderColor: 'rgb(255, 255, 255)', borderRadius: '0px', fontSize: '16px', fontWeight: '400', lineHeight: '24px', padding: '1px 6px', opacity: '1', }); expect(geometry.secondaryStyle).toEqual({ color: 'rgb(255, 255, 255)', backgroundColor: 'rgb(68, 68, 68)', borderColor: 'rgb(61, 61, 61)', borderRadius: '6px', fontSize: '16px', fontWeight: '700', lineHeight: '24px', padding: '6px 12px', }); expect(geometry.applyStyle).toEqual({ color: 'rgb(255, 255, 255)', backgroundColor: 'rgb(55, 90, 127)', borderColor: 'rgb(50, 81, 114)', borderRadius: '6px', fontSize: '16px', fontWeight: '700', lineHeight: '24px', padding: '6px 12px', transitionDuration: '0.15s, 0.15s, 0.15s, 0.15s', transitionProperty: 'color, background-color, border-color, box-shadow', }); expect(geometry.backdropStyle).toEqual({ transitionDuration: '0.15s', transitionProperty: 'opacity', }); expect(geometry.motionStyle).toEqual({ transitionDuration: '0.3s', transitionProperty: 'transform', transitionTimingFunction: 'ease-out', }); const apply = page.getByTestId('icon-server-apply'); const baseBackground = await apply.evaluate((element) => getComputedStyle(element).backgroundColor); await apply.hover(); expect(await apply.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe(baseBackground); await page.getByTestId('icon-server-option-hwe:903').focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await expect(apply).toBeFocused(); expect( await apply.evaluate((element) => ({ outlineStyle: getComputedStyle(element).outlineStyle, outlineWidth: getComputedStyle(element).outlineWidth, })) ).toEqual({ outlineStyle: 'none', outlineWidth: '0px' }); const applyBox = await apply.boundingBox(); expect(applyBox).not.toBeNull(); await page.mouse.move(applyBox!.x + applyBox!.width / 2, applyBox!.y + applyBox!.height / 2); await page.mouse.down(); expect(await apply.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe(baseBackground); await page.mouse.move(5, 5); await page.mouse.up(); await page.mouse.click(5, 5); await expect(page.getByTestId('icon-server-modal')).toBeVisible(); await expect .poll(() => page.locator('.icon-server-dialog').evaluate((element) => getComputedStyle(element).transform !== 'none') ) .toBe(true); await expect .poll(() => page.locator('.icon-server-dialog').evaluate((element) => getComputedStyle(element).transform)) .toBe('none'); await page.keyboard.press('Escape'); await expect(page.getByTestId('icon-server-modal')).toBeVisible(); }); test('contains focus and long failure content inside a 320px viewport', async ({ page }) => { await page.setViewportSize({ width: 320, height: 568 }); await installFixture(page, { failHweAdjustOnce: true }); await page.goto('account'); await uploadIcon(page); await page.getByTestId('icon-server-apply').click(); await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('HWE 아이콘 적용 실패'); const containment = await page.evaluate(() => { const selectors = [ '.icon-server-backdrop', '.icon-server-dialog', '.icon-server-header', '.icon-server-body', '.icon-server-footer', ]; const dialog = document.querySelector('.icon-server-dialog')!; const rect = dialog.getBoundingClientRect(); return { rect: { left: rect.left, right: rect.right, }, regionsFit: selectors.every((selector) => { const element = document.querySelector(selector)!; return element.scrollWidth <= element.clientWidth; }), focusInside: dialog.contains(document.activeElement), }; }); expect(containment.rect.left).toBeGreaterThanOrEqual(0); expect(containment.rect.right).toBeLessThanOrEqual(320); expect(containment.regionsFit).toBe(true); expect(containment.focusInside).toBe(true); await page.getByRole('button', { name: '아이콘 변경' }).focus(); await expect .poll(() => page.evaluate(() => document.querySelector('.icon-server-dialog')?.contains(document.activeElement) ) ) .toBe(true); }); test('matches the Ref one-server modal geometry at 320px', async ({ page }, testInfo) => { await page.setViewportSize({ width: 320, height: 568 }); await installFixture(page); await page.goto('account'); page.once('dialog', (dialog) => dialog.accept()); await page.getByRole('button', { name: '아이콘 제거' }).click(); await expect(page.getByTestId('icon-server-modal')).toBeVisible(); await page.evaluate(() => document.fonts.ready); await page.locator('.icon-server-dialog').screenshot({ path: testInfo.outputPath('core-icon-modal-mobile.png'), animations: 'disabled', }); const geometry = await page.evaluate(() => { const rect = (selector: string) => { const value = document.querySelector(selector)!.getBoundingClientRect(); return { x: value.x, y: value.y, width: value.width, height: value.height }; }; const textLines = (selector: string) => { const root = document.querySelector(selector)!; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const lines = new Map(); let node = walker.nextNode(); while (node) { for (let offset = 0; offset < (node.textContent?.length ?? 0); offset += 1) { const range = document.createRange(); range.setStart(node, offset); range.setEnd(node, offset + 1); const box = range.getBoundingClientRect(); if (box.width > 0) { const top = Math.round(box.top); lines.set(top, `${lines.get(top) ?? ''}${node.textContent?.[offset] ?? ''}`); } } node = walker.nextNode(); } return [...lines.entries()].sort(([left], [right]) => left - right).map(([, text]) => text); }; return { dialog: rect('.icon-server-dialog'), header: rect('.icon-server-header'), title: rect('.icon-server-header h2'), body: rect('.icon-server-body'), footer: rect('.icon-server-footer'), close: rect('[data-testid="icon-server-close"]'), apply: rect('[data-testid="icon-server-apply"]'), titleLines: textLines('.icon-server-header h2'), titleFontMetrics: (() => { const style = getComputedStyle(document.querySelector('.icon-server-header h2')!); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d')!; context.font = style.font; return { font: context.font, textWidth: context.measureText('새 아이콘을 적용할 서버를 선택하세요.').width, selectionPrefixWidth: context.measureText('새 아이콘을 적용할 서버를 선택').width, }; })(), }; }); expect(geometry).toEqual({ dialog: { x: 8, y: 8, width: 304, height: 254 }, header: { x: 9, y: 9, width: 302, height: 123 }, title: { x: 25, y: 25, width: 244, height: 90 }, body: { x: 9, y: 132, width: 302, height: 56 }, footer: { x: 9, y: 188, width: 302, height: 73 }, close: { x: 269, y: 55, width: 26, height: 30 }, apply: { x: 209, y: 205, width: 86, height: 40 }, titleLines: ['완료되었습니다.', '새 아이콘을 적용할 서버를 선택', '하세요.'], titleFontMetrics: { font: '500 20px Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"', textWidth: 297, selectionPrefixWidth: 241, }, }); }); test('reopens server synchronization without consuming the daily icon change', async ({ page }) => { await installFixture(page); await page.goto('account'); await page.getByRole('button', { name: '현재 아이콘 서버 적용' }).click(); await expect(page.getByTestId('icon-server-modal')).toBeVisible(); await expect(page.getByTestId('icon-server-option-che:903')).toBeChecked(); await expect(page.getByTestId('icon-server-option-hwe:903')).toBeChecked(); });