test(e2e): make parity runners environment-safe

This commit is contained in:
2026-07-31 18:17:54 +00:00
parent ab0c050847
commit 62152ba8d9
7 changed files with 63 additions and 33 deletions
+2
View File
@@ -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",
@@ -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: '.',
@@ -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/`,
@@ -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) => {
@@ -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) => {
+7
View File
@@ -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}`;
@@ -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();