From 62152ba8d9f20f2b696fa7c6ed7f8a027bcf73e9 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 18:17:54 +0000 Subject: [PATCH] test(e2e): make parity runners environment-safe --- package.json | 2 + .../main-front-status.playwright.config.mjs | 4 +- .../main-records.playwright.config.mjs | 6 +- .../frontend-legacy-parity/map-trend.spec.ts | 9 +-- .../public-gaps.spec.ts | 7 ++- tools/frontend-legacy-parity/testUrls.ts | 7 +++ .../visual-parity.spec.ts | 61 ++++++++++++------- 7 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 tools/frontend-legacy-parity/testUrls.ts diff --git a/package.json b/package.json index 04e4b71..828678d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check", "check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check", "test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json", + "test:e2e:main-front-status-live": "node tools/frontend-legacy-parity/run-main-front-status-live.mjs", + "test:e2e:main-records-live": "node tools/frontend-legacy-parity/run-main-records-live.mjs", "migrate:legacy": "pnpm --filter @sammo-ts/legacy-db-migration migrate", "docs:generate": "node tools/docs/generate-command-reference.mjs", "docs:dev": "pnpm docs:generate && vitepress dev docs", diff --git a/tools/frontend-legacy-parity/main-front-status.playwright.config.mjs b/tools/frontend-legacy-parity/main-front-status.playwright.config.mjs index a9964cb..2bc10d9 100644 --- a/tools/frontend-legacy-parity/main-front-status.playwright.config.mjs +++ b/tools/frontend-legacy-parity/main-front-status.playwright.config.mjs @@ -4,8 +4,8 @@ import { fileURLToPath } from 'node:url'; import { defineConfig, devices } from '@playwright/test'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const frontendPort = 15112; -const apiPort = 15113; +const frontendPort = Number(process.env.FRONTEND_PARITY_LIVE_FRONTEND_PORT ?? 15112); +const apiPort = Number(process.env.FRONTEND_PARITY_LIVE_API_PORT ?? 15113); export default defineConfig({ testDir: '.', diff --git a/tools/frontend-legacy-parity/main-records.playwright.config.mjs b/tools/frontend-legacy-parity/main-records.playwright.config.mjs index 934c9e9..5ad9f9f 100644 --- a/tools/frontend-legacy-parity/main-records.playwright.config.mjs +++ b/tools/frontend-legacy-parity/main-records.playwright.config.mjs @@ -4,8 +4,8 @@ import { fileURLToPath } from 'node:url'; import { defineConfig, devices } from '@playwright/test'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const frontendPort = 15112; -const apiPort = 15113; +const frontendPort = Number(process.env.FRONTEND_PARITY_LIVE_FRONTEND_PORT ?? 15112); +const apiPort = Number(process.env.FRONTEND_PARITY_LIVE_API_PORT ?? 15113); export default defineConfig({ testDir: '.', @@ -15,7 +15,7 @@ export default defineConfig({ timeout: 60_000, expect: { timeout: 10_000 }, reporter: [['list']], - outputDir: resolve(repositoryRoot, 'test-results/main-records-live'), + outputDir: process.env.SAMMO_TEST_OUTPUT_DIR ?? resolve(repositoryRoot, 'test-results/main-records-live'), use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${frontendPort}/che/`, diff --git a/tools/frontend-legacy-parity/map-trend.spec.ts b/tools/frontend-legacy-parity/map-trend.spec.ts index b96f68c..7e27499 100644 --- a/tools/frontend-legacy-parity/map-trend.spec.ts +++ b/tools/frontend-legacy-parity/map-trend.spec.ts @@ -4,6 +4,7 @@ import { dirname, extname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { canonicalFrontendFixture as fixture } from './fixtures/canonical'; +import { gameUrl, gatewayUrl } from './testUrls.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; @@ -211,7 +212,7 @@ const installMainFixture = async (page: Page, failRecords = false) => { test('shows the representative cached map and cached history before login', async ({ page }) => { await installGatewayMapFixture(page); await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto('http://127.0.0.1:15100/gateway/'); + await page.goto(gatewayUrl()); await expect(page.locator('.map-preview-body')).toBeVisible(); await expect(page.locator('.status-history')).toContainText('유비가 촉을 건국하였습니다.'); @@ -237,7 +238,7 @@ test('shows the representative cached map and cached history before login', asyn test('shows the current in-game map and all three recent record streams', async ({ page }) => { await installMainFixture(page); await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto('http://127.0.0.1:15102/che/'); + await page.goto(gameUrl()); await expect(page.locator('.map-area')).toBeVisible(); await expect(page.getByText('관우가 장수 동향을 남겼습니다.')).toBeVisible(); @@ -267,7 +268,7 @@ test('shows the current in-game map and all three recent record streams', async test('keeps the current map visible when the recent record request fails', async ({ page }) => { await installMainFixture(page, true); await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto('http://127.0.0.1:15102/che/'); + await page.goto(gameUrl()); await expect(page.locator('.map-area')).toBeVisible(); await expect(page.getByRole('alert').first()).toContainText('동향 정보를 불러오지 못했습니다.'); @@ -277,7 +278,7 @@ test('keeps the current map visible when the recent record request fails', async test('shows map and trend tabs in the mobile in-game layout', async ({ page }) => { await installMainFixture(page); await page.setViewportSize({ width: 500, height: 900 }); - await page.goto('http://127.0.0.1:15102/che/'); + await page.goto(gameUrl()); await expect(page.locator('.map-area')).toBeVisible(); const mapGeometry = await page.locator('.map-area').evaluate((element) => { diff --git a/tools/frontend-legacy-parity/public-gaps.spec.ts b/tools/frontend-legacy-parity/public-gaps.spec.ts index f5582c0..9f8786f 100644 --- a/tools/frontend-legacy-parity/public-gaps.spec.ts +++ b/tools/frontend-legacy-parity/public-gaps.spec.ts @@ -2,6 +2,7 @@ import { expect, test, type Page, type Route } from '@playwright/test'; import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gameUrl } from './testUrls.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')]; @@ -204,7 +205,7 @@ const installNpcFixture = async (page: Page) => { test('nation betting matches the legacy desktop geometry and preserves a failed form', async ({ page }) => { await installBettingFixture(page); await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto('http://127.0.0.1:15102/che/nation-betting'); + await page.goto(gameUrl('/nation-betting')); await expect(page.getByRole('button', { name: /천통국 예상/ })).toBeVisible(); await page.getByRole('button', { name: /천통국 예상/ }).click(); await expect(page.locator('.betting-candidate')).toHaveCount(6); @@ -315,7 +316,7 @@ test('nation betting matches the legacy desktop geometry and preserves a failed test('nation betting keeps the legacy 500px three-column mobile contract', async ({ page }) => { await installBettingFixture(page); await page.setViewportSize({ width: 500, height: 900 }); - await page.goto('http://127.0.0.1:15102/che/nation-betting'); + await page.goto(gameUrl('/nation-betting')); await page.getByRole('button', { name: /천통국 예상/ }).click(); await expect(page.locator('.betting-candidate')).toHaveCount(6); @@ -345,7 +346,7 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async test('NPC list matches the legacy table geometry, sorting and error retention', async ({ page }) => { await installNpcFixture(page); await page.setViewportSize({ width: 1024, height: 800 }); - await page.goto('http://127.0.0.1:15102/che/npc-list'); + await page.goto(gameUrl('/npc-list')); await expect(page.locator('.npc-table tbody tr')).toHaveCount(2); const geometry = await page.locator('#npc-list-container').evaluate((container) => { diff --git a/tools/frontend-legacy-parity/testUrls.ts b/tools/frontend-legacy-parity/testUrls.ts new file mode 100644 index 0000000..118a6a0 --- /dev/null +++ b/tools/frontend-legacy-parity/testUrls.ts @@ -0,0 +1,7 @@ +const gatewayPort = process.env.FRONTEND_PARITY_GATEWAY_PORT ?? '15100'; +const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'; + +export const gatewayOrigin = `http://127.0.0.1:${gatewayPort}`; +export const gameOrigin = `http://127.0.0.1:${gamePort}`; +export const gatewayUrl = (suffix = '/'): string => `${gatewayOrigin}/gateway${suffix}`; +export const gameUrl = (suffix = '/'): string => `${gameOrigin}/che${suffix}`; diff --git a/tools/frontend-legacy-parity/visual-parity.spec.ts b/tools/frontend-legacy-parity/visual-parity.spec.ts index 280387e..c785538 100644 --- a/tools/frontend-legacy-parity/visual-parity.spec.ts +++ b/tools/frontend-legacy-parity/visual-parity.spec.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import { generateKeyPairSync } from 'node:crypto'; import { canonicalFrontendFixture as fixture } from './fixtures/canonical'; +import { gameOrigin, gameUrl, gatewayUrl } from './testUrls.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const imageRoots = [ @@ -328,7 +329,7 @@ test.describe('gateway legacy parity', () => { const mapImage = page.waitForResponse((response) => response.url().endsWith('/image/game/map/che/bg_fall.jpg') ); - await page.goto('http://127.0.0.1:15100/gateway/'); + await page.goto(gatewayUrl()); expect((await mapImage).ok()).toBe(true); await expect(page.locator('#login_card')).toBeVisible(); await expect(page.locator('.map-preview-body')).toBeVisible(); @@ -381,7 +382,7 @@ test.describe('gateway legacy parity', () => { } test('submits the real login mutation and stores the session', async ({ page }) => { - await page.goto('http://127.0.0.1:15100/gateway/'); + await page.goto(gatewayUrl()); await page.locator('#username').fill('visual-user'); await page.locator('#password').fill('visual-password'); await page.locator('.login-button').click(); @@ -395,7 +396,7 @@ test.describe('gateway legacy parity', () => { await page.addInitScript((token) => { window.localStorage.setItem('sammo-session-token', token); }, fixture.gateway.sessionToken); - await page.goto('http://127.0.0.1:15100/gateway/account'); + await page.goto(gatewayUrl('/account')); await expect(page.getByText('시각검증')).toBeVisible(); if (artifactRoot) { await page.screenshot({ @@ -447,7 +448,7 @@ test.describe('gateway legacy parity', () => { } await route.fallback(); }); - await page.goto('http://127.0.0.1:15100/gateway/account'); + await page.goto(gatewayUrl('/account')); await page.locator('#current-password').fill('wrong-password'); await page.locator('#new-password').fill('next-password'); await page.locator('#confirm-password').fill('next-password'); @@ -457,7 +458,7 @@ test.describe('gateway legacy parity', () => { }); test('completes the OAuth registration page and stores the session', async ({ page }) => { - await page.goto('http://127.0.0.1:15100/gateway/oauth/callback?code=visual-code&state=visual-state'); + await page.goto(gatewayUrl('/oauth/callback?code=visual-code&state=visual-state')); await expect(page.getByLabel('카카오 이메일')).toHaveValue('visual@example.test'); await expect(page.getByRole('link', { name: '내용 확인' }).first()).toHaveAttribute( 'href', @@ -510,7 +511,7 @@ test.describe('gateway legacy parity', () => { } await route.fallback(); }); - await page.goto('http://127.0.0.1:15100/gateway/oauth/callback?code=visual-code&state=visual-state'); + await page.goto(gatewayUrl('/oauth/callback?code=visual-code&state=visual-state')); await page.locator('#oauth-username').fill('duplicate-user'); await page.locator('#oauth-password').fill('visual-password'); await page.locator('#oauth-confirm').fill('visual-password'); @@ -528,7 +529,7 @@ test.describe('gateway legacy parity', () => { ]) { test(`renders the local signup form at ref-compatible geometry on ${viewport.name}`, async ({ page }) => { await page.setViewportSize(viewport); - await page.goto('http://127.0.0.1:15100/gateway/signup'); + await page.goto(gatewayUrl('/signup')); await expect(page.getByRole('heading', { name: '회원가입' })).toBeVisible(); await expect(page.locator('iframe[title="이용 약관"]')).toBeVisible(); await expect(page.locator('iframe[title="개인정보 제공 및 이용 동의"]')).toBeVisible(); @@ -566,7 +567,7 @@ test.describe('gateway legacy parity', () => { } await route.fallback(); }); - await page.goto('http://127.0.0.1:15100/gateway/signup'); + await page.goto(gatewayUrl('/signup')); await page.locator('#signup-username').fill('VISUAL-LOCAL'); await page.locator('#signup-password').fill('visual-password'); await page.locator('#signup-confirm-password').fill('visual-password'); @@ -618,7 +619,7 @@ test.describe('gateway legacy parity', () => { } await route.fallback(); }); - await page.goto('http://127.0.0.1:15100/gateway/signup'); + await page.goto(gatewayUrl('/signup')); await page.locator('#signup-username').fill('duplicate-local'); await page.locator('#signup-password').fill('visual-password'); await page.locator('#signup-confirm-password').fill('visual-password'); @@ -646,7 +647,7 @@ test.describe('best general legacy parity', () => { ]) { test(`matches the ref fixed ranking grid on ${viewport.name}`, async ({ page }) => { await page.setViewportSize(viewport); - await page.goto('http://127.0.0.1:15102/che/best-general'); + await page.goto(gameUrl('/best-general')); await expect(page.getByText('유비').first()).toBeVisible(); await expect(page.locator('.rankView')).toHaveCount(3); if (artifactRoot) { @@ -711,6 +712,24 @@ test.describe('best general legacy parity', () => { expect(geometry.image.naturalWidth).toBeGreaterThan(0); const npcButton = page.getByRole('button', { name: 'NPC 보기' }); + const npcButtonBox = await npcButton.boundingBox(); + expect(npcButtonBox).not.toBeNull(); + await page.mouse.move( + npcButtonBox!.x + npcButtonBox!.width / 2, + npcButtonBox!.y + npcButtonBox!.height / 2 + ); + await page.mouse.down(); + await expect(npcButton).toHaveCSS('background-color', 'rgb(107, 107, 107)'); + if (artifactRoot) { + await npcButton.screenshot({ + path: resolve(artifactRoot, `best-general-active-${viewport.name}.png`), + animations: 'disabled', + }); + } + await page.mouse.up(); + await npcButton.evaluate((element) => element.blur()); + await page.mouse.move(0, 0); + await expect(npcButton).toHaveCSS('background-color', 'rgb(55, 90, 127)'); await npcButton.hover(); await expect(npcButton).toHaveCSS('background-color', 'rgb(107, 107, 107)'); await npcButton.focus(); @@ -725,7 +744,7 @@ test.describe('best general legacy parity', () => { } test('keeps the current ranking and selected user type after an API error', async ({ page }) => { - await page.goto('http://127.0.0.1:15102/che/best-general'); + await page.goto(gameUrl('/best-general')); await expect(page.getByText('유비').first()).toBeVisible(); await page.route('**/che/api/trpc/**', async (route) => { if (operationNames(route).includes('ranking.getBestGeneral')) { @@ -759,7 +778,7 @@ test.describe('hall of fame legacy parity', () => { ]) { test(`matches the ref fixed grid on ${viewport.name}`, async ({ page }) => { await page.setViewportSize(viewport); - await page.goto('http://127.0.0.1:15102/che/hall-of-fame'); + await page.goto(gameUrl('/hall-of-fame')); await expect(page.getByText('유비')).toBeVisible(); await expect.poll(() => hallCalls.options).toBe(1); await expect.poll(() => hallCalls.hallInputs.length).toBe(1); @@ -836,7 +855,7 @@ test.describe('hall of fame legacy parity', () => { } test('keeps the selected scenario after a hall API error', async ({ page }) => { - await page.goto('http://127.0.0.1:15102/che/hall-of-fame'); + await page.goto(gameUrl('/hall-of-fame')); await expect(page.getByText('유비')).toBeVisible(); let failedHallRequests = 0; await page.route('**/che/api/trpc/**', async (route) => { @@ -863,13 +882,13 @@ test.describe('hall of fame legacy parity', () => { }); test('game login delegates to the gateway like the ref entry point', async ({ page }) => { - await page.goto('http://127.0.0.1:15102/che/login'); - await expect(page).toHaveURL('http://127.0.0.1:15102/gateway/'); + await page.goto(gameUrl('/login')); + await expect(page).toHaveURL(`${gameOrigin}/gateway/`); }); test('canonical logged-in fixture passes the game route guard', async ({ page }) => { await installAuthenticatedGameFixture(page); - await page.goto('http://127.0.0.1:15102/che/troop'); + await page.goto(gameUrl('/troop')); await expect(page).toHaveURL(/\/che\/troop$/); await expect(page.getByText('부대 편성')).toBeVisible(); }); @@ -885,7 +904,7 @@ test.describe('yearbook legacy parity', () => { ]) { test(`renders the ref yearbook grid on ${viewport.name}`, async ({ page }) => { await page.setViewportSize(viewport); - await page.goto('http://127.0.0.1:15102/che/yearbook'); + await page.goto(gameUrl('/yearbook')); await expect(page.getByText('한이 낙양을 지키고 있습니다.')).toBeVisible(); if (artifactRoot) { await page.screenshot({ @@ -939,7 +958,7 @@ test.describe('yearbook legacy parity', () => { } await route.fallback(); }); - await page.goto('http://127.0.0.1:15102/che/yearbook'); + await page.goto(gameUrl('/yearbook')); await expect(page.getByRole('alert')).toBeVisible(); await expect(page.getByLabel('연월 선택')).toBeVisible(); }); @@ -956,7 +975,7 @@ test.describe('survey legacy parity', () => { ]) { test(`renders the ref vote tables on ${viewport.name}`, async ({ page }) => { await page.setViewportSize(viewport); - await page.goto('http://127.0.0.1:15102/che/survey'); + await page.goto(gameUrl('/survey')); await expect(page.getByText('설문 조사(90금과 추첨으로 유니크템 증정!)')).toBeVisible(); await expect(page.getByText('기병이 좋습니다.')).toBeVisible(); await expect(page.locator('#vote-new-panel')).toHaveCount(0); @@ -1015,7 +1034,7 @@ test.describe('survey legacy parity', () => { } test('submits a vote and comment through the real screen controls', async ({ page }) => { - await page.goto('http://127.0.0.1:15102/che/survey'); + await page.goto(gameUrl('/survey')); await page.getByRole('button', { name: '투표', exact: true }).click(); await expect(page.getByRole('status')).toHaveText('설문을 마쳤습니다.'); await expect(page.getByText('결산', { exact: true })).toBeVisible(); @@ -1037,7 +1056,7 @@ test.describe('survey legacy parity', () => { } await route.fallback(); }); - await page.goto('http://127.0.0.1:15102/che/survey'); + await page.goto(gameUrl('/survey')); const secondOption = page.locator('#v-vote-1'); await secondOption.check(); await page.getByRole('button', { name: '투표', exact: true }).click();