import { access, readFile } from 'node:fs/promises'; import path from 'node:path'; import { expect, test, type Browser, type Page, type TestInfo } from '@playwright/test'; const requiredEnv = (name: string): string => { const value = process.env[name]?.trim(); if (!value) { throw new Error(`${name} is required`); } return value; }; const readPassword = async (account: 'admin'): Promise => { const root = requiredEnv('SAMMO_LIFECYCLE_SECRET_ROOT'); return (await readFile(`${root}/${account}_password`, 'utf8')).trim(); }; const login = async (page: Page, username: string, password: string): Promise => { await page.goto('/gateway/'); await page.getByLabel('계정명').fill(username); await page.getByLabel('비밀번호').fill(password); await page.getByRole('button', { name: '로그인', exact: true }).click(); await expect(page).toHaveURL(/\/gateway\/lobby$/); }; const hweRow = (page: Page) => page.locator('tbody tr').filter({ hasText: /^hwe섭/ }); const resetScenario = async (page: Page, scenarioId: string, sourceCommit: string): Promise => { await page.goto('/gateway/admin/servers/hwe%3A2/scenario'); await page.getByTestId('source-commit').check(); await page.getByTestId('source-ref').fill(sourceCommit); await page.getByTestId('load-scenarios').click(); await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible(); await page.getByTestId('scenario-select').selectOption(scenarioId); const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first(); const previousLatestOperation = await latestOperation.textContent(); await page.getByTestId('request-reset').click(); await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible(); await expect.poll(() => latestOperation.textContent(), { timeout: 15_000 }).not.toBe(previousLatestOperation); await expect(latestOperation).toContainText(sourceCommit, { timeout: 15_000 }); await expect(latestOperation.locator('td').nth(3)).toHaveText('SUCCEEDED', { timeout: 300_000, }); const profileStatus = page.getByTestId('selected-profile-status'); await expect(profileStatus.locator(':scope > div').nth(0)).toContainText('RUNNING', { timeout: 30_000, }); await expect(profileStatus.locator(':scope > div').nth(1)).toContainText('SUCCEEDED'); await expect(profileStatus.locator('.text-emerald-400')).toHaveCount(3, { timeout: 30_000, }); await expect(profileStatus.locator('..').locator('.text-red-400')).toHaveCount(0); await expect .poll( () => page.evaluate(async () => { const response = await fetch( '/hwe/api/trpc/lobby.info,public.getMapLayout,public.getCachedMap?batch=1&input=%7B%7D' ); return response.status; }), { timeout: 60_000 } ) .toBe(200); }; type PossessCandidateBatch = Array<{ result?: { data?: { candidates?: Array<{ picture?: string | null }>; }; }; }>; const inspectScenarioIcons = async ( browser: Browser, testInfo: TestInfo, scenarioId: string, expectedDirectory: string ): Promise => { const context = await browser.newContext({ ignoreHTTPSErrors: true, colorScheme: 'dark', viewport: { width: 1280, height: 900 }, }); const page = await context.newPage(); await login(page, 'guiadmin', await readPassword('admin')); const row = hweRow(page); await expect(row.getByRole('button', { name: '장수생성' })).toBeEnabled({ timeout: 60_000 }); await row.getByRole('button').click(); await expect(page).toHaveURL(/\/hwe\/join$/); const candidatesResponse = page.waitForResponse( (candidate) => candidate.url().includes('/hwe/api/trpc/join.listPossessCandidates') && candidate.status() === 200 ); await page.getByRole('button', { name: 'NPC 빙의' }).click(); const payload = (await (await candidatesResponse).json()) as PossessCandidateBatch; const iconPaths = payload .flatMap((entry) => entry.result?.data?.candidates ?? []) .map((candidate) => candidate.picture) .filter( (picture): picture is string => typeof picture === 'string' && picture.startsWith(`${expectedDirectory}/`) ); expect(iconPaths.length).toBeGreaterThan(0); const imageRoot = requiredEnv('SAMMO_IMAGE_ROOT'); for (const iconPath of iconPaths) { await access(path.join(imageRoot, 'icons', iconPath)); } await page.screenshot({ path: testInfo.outputPath(`scenario-${scenarioId}-npc-icons.png`), fullPage: true, }); await context.close(); return iconPaths; }; test('Chromium resets numeric and null-picture scenarios to repository-backed name paths', async ({ browser, page, }, testInfo) => { test.setTimeout(720_000); const sourceCommit = requiredEnv('SAMMO_LIFECYCLE_SOURCE_COMMIT'); page.on('dialog', (dialog) => dialog.accept()); await login(page, 'guiadmin', await readPassword('admin')); await resetScenario(page, '1010', sourceCommit); const numericScenarioIcons = await inspectScenarioIcons(browser, testInfo, '1010', '장수'); expect(numericScenarioIcons.every((iconPath) => iconPath.startsWith('장수/'))).toBe(true); await resetScenario(page, '2220', sourceCommit); const nullScenarioIcons = await inspectScenarioIcons(browser, testInfo, '2220', '장수'); expect(nullScenarioIcons.every((iconPath) => iconPath.startsWith('장수/'))).toBe(true); await resetScenario(page, '2140', sourceCommit); const themedScenarioIcons = await inspectScenarioIcons(browser, testInfo, '2140', '걸그룹'); expect(themedScenarioIcons.every((iconPath) => iconPath.startsWith('걸그룹/'))).toBe(true); });