diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 99aaa5ba..145ebe9f 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -84,6 +84,9 @@ export const createGameApiServer = async () => { const app = fastify({ logger: true, + routerOptions: { + maxParamLength: 2048, + }, }); await app.register(cors, { diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 1e10e980..d3c82626 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -273,6 +273,9 @@ const parseInstallOptions = ( const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string => `sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`; +const isMissingProcessError = (error: unknown): boolean => + error instanceof Error && /process or namespace not found/i.test(error.message); + export const buildProcessDefinitions = ( profile: GatewayProfileRecord, config: GatewayProcessConfig @@ -293,6 +296,9 @@ export const buildProcessDefinitions = ( PROFILE: profile.profile, SCENARIO: profile.scenario, GAME_API_PORT: String(profile.apiPort), + GAME_TRPC_PATH: `/${profile.profile}/api/trpc`, + GAME_API_EVENTS_PATH: `/${profile.profile}/api/events`, + GAME_UPLOAD_PATH: `/${profile.profile}/api/uploads`, GATEWAY_REDIS_PREFIX: config.redisKeyPrefix, GAME_TOKEN_SECRET: config.gameTokenSecret, }; @@ -963,14 +969,21 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async stopProfile(profile: GatewayProfileRecord): Promise { const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const existingNames = new Set((await this.processManager.list()).map((process) => process.name)); const failures: string[] = []; for (const name of [apiName, daemonName]) { + if (!existingNames.has(name)) { + continue; + } try { await this.processManager.stop(name); } catch { - try { - await this.processManager.delete(name); - } catch (error) { + // Deleting the definition below also terminates a process that raced with stop. + } + try { + await this.processManager.delete(name); + } catch (error) { + if (!isMissingProcessError(error)) { failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); } } diff --git a/app/gateway-api/src/orchestrator/workspaceRoot.ts b/app/gateway-api/src/orchestrator/workspaceRoot.ts index 6c1cc07b..b75ef666 100644 --- a/app/gateway-api/src/orchestrator/workspaceRoot.ts +++ b/app/gateway-api/src/orchestrator/workspaceRoot.ts @@ -1,22 +1,21 @@ import fs from 'node:fs'; import path from 'node:path'; -const WORKSPACE_MARKERS = ['pnpm-workspace.yaml', 'package.json']; - -const hasWorkspaceMarker = (dir: string): boolean => - WORKSPACE_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker))); - export const resolveWorkspaceRoot = (startDir: string, maxDepth = 5): string => { let current = path.resolve(startDir); + let packageRoot: string | null = null; for (let depth = 0; depth <= maxDepth; depth += 1) { - if (hasWorkspaceMarker(current)) { + if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) { return current; } + if (!packageRoot && fs.existsSync(path.join(current, 'package.json'))) { + packageRoot = current; + } const parent = path.dirname(current); if (parent === current) { break; } current = parent; } - return path.resolve(startDir); + return packageRoot ?? path.resolve(startDir); }; diff --git a/app/gateway-api/src/scenario/scenarioCatalog.ts b/app/gateway-api/src/scenario/scenarioCatalog.ts index a76599c0..6d94ea8f 100644 --- a/app/gateway-api/src/scenario/scenarioCatalog.ts +++ b/app/gateway-api/src/scenario/scenarioCatalog.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '@sammo-ts/game-engine'; import { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic'; +import { resolveWorkspaceRoot } from '../orchestrator/workspaceRoot.js'; export interface ScenarioNationPreview { id: number; @@ -34,7 +35,7 @@ const SCENARIO_ROOT = path.join('resources', 'scenario'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +const REPO_ROOT = resolveWorkspaceRoot(process.env.GATEWAY_WORKSPACE_ROOT ?? __dirname); const previewCache = new Map(); const defaultsCache = new Map(); diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index d539395a..48184a28 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -36,7 +36,13 @@ const buildOperation = (type: 'START' | 'STOP'): GatewayOperationRecord => ({ updatedAt: '2026-07-25T01:00:00.000Z', }); -const createHarness = (operation: GatewayOperationRecord, failStart = false, failStop = false) => { +const createHarness = ( + operation: GatewayOperationRecord, + failStart = false, + failStop = false, + processesPresent = true, + missingOnDelete = false +) => { let nextOperation: GatewayOperationRecord | null = operation; const statuses: string[] = []; const completions: GatewayOperationStatus[] = []; @@ -77,7 +83,13 @@ const createHarness = (operation: GatewayOperationRecord, failStart = false, fai retryOperation: async () => null, }; const processManager: ProcessManager = { - list: async () => [], + list: async () => + processesPresent + ? [ + { name: 'sammo:che:2:game-api', status: 'online' }, + { name: 'sammo:che:2:turn-daemon', status: 'online' }, + ] + : [], start: async (definition) => { if (failStart) { throw new Error('pm2 unavailable'); @@ -92,6 +104,9 @@ const createHarness = (operation: GatewayOperationRecord, failStart = false, fai }, delete: async (name) => { deleted.push(name); + if (missingOnDelete) { + throw new Error('process or namespace not found'); + } if (failStop) { throw new Error('pm2 delete failed'); } @@ -142,6 +157,27 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.statuses).toEqual(['STOPPED']); expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); + expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); + expect(harness.completions).toEqual(['SUCCEEDED']); + }); + + it('treats an already stopped profile as a successful idempotent stop', async () => { + const harness = createHarness(buildOperation('STOP'), false, false, false); + + await harness.orchestrator.runOperationsNow(); + + expect(harness.statuses).toEqual(['STOPPED']); + expect(harness.stopped).toEqual([]); + expect(harness.deleted).toEqual([]); + expect(harness.completions).toEqual(['SUCCEEDED']); + }); + + it('treats a process removed concurrently as a successful stop', async () => { + const harness = createHarness(buildOperation('STOP'), false, false, true, true); + + await harness.orchestrator.runOperationsNow(); + + expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); expect(harness.completions).toEqual(['SUCCEEDED']); }); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 6e893b5c..9f6f8bc4 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -83,6 +83,11 @@ describe('buildProcessDefinitions', () => { expect(definitions.api.cwd).toBe(path.join(buildWorkspace, 'app', 'game-api')); expect(definitions.api.script).toBe(path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js')); + expect(definitions.api.env).toMatchObject({ + GAME_TRPC_PATH: '/che/api/trpc', + GAME_API_EVENTS_PATH: '/che/api/events', + GAME_UPLOAD_PATH: '/che/api/uploads', + }); expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine')); expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js')); }); diff --git a/app/gateway-api/test/workspaceRoot.test.ts b/app/gateway-api/test/workspaceRoot.test.ts new file mode 100644 index 00000000..385adb27 --- /dev/null +++ b/app/gateway-api/test/workspaceRoot.test.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveWorkspaceRoot } from '../src/orchestrator/workspaceRoot.js'; + +const tempDirs: string[] = []; + +const makeTempDir = (): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-workspace-root-')); + tempDirs.push(dir); + return dir; +}; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('resolveWorkspaceRoot', () => { + it('prefers the pnpm workspace above a nested package', () => { + const root = makeTempDir(); + const packageDir = path.join(root, 'app', 'gateway-api'); + const sourceDir = path.join(packageDir, 'dist'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages: []\n'); + fs.writeFileSync(path.join(packageDir, 'package.json'), '{}\n'); + + expect(resolveWorkspaceRoot(sourceDir)).toBe(root); + }); + + it('falls back to the nearest package when there is no workspace marker', () => { + const root = makeTempDir(); + const sourceDir = path.join(root, 'src', 'nested'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), '{}\n'); + + expect(resolveWorkspaceRoot(sourceDir)).toBe(root); + }); +}); diff --git a/app/gateway-frontend/e2e/hwe-lifecycle.playwright.config.mjs b/app/gateway-frontend/e2e/hwe-lifecycle.playwright.config.mjs new file mode 100644 index 00000000..945bb94c --- /dev/null +++ b/app/gateway-frontend/e2e/hwe-lifecycle.playwright.config.mjs @@ -0,0 +1,37 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +export default defineConfig({ + testDir: '.', + testMatch: 'hwe-lifecycle.spec.ts', + fullyParallel: false, + workers: 1, + timeout: 360_000, + globalTimeout: 420_000, + expect: { + timeout: 15_000, + }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/hwe-lifecycle'), + use: { + baseURL: process.env.SAMMO_LIFECYCLE_BASE_URL ?? 'http://127.0.0.1:15140', + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + colorScheme: 'dark', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + ignoreHTTPSErrors: true, + actionTimeout: 15_000, + navigationTimeout: 30_000, + }, + webServer: { + command: 'node e2e/prefix-proxy.mjs', + cwd: resolve(repositoryRoot, 'app/gateway-frontend'), + url: 'http://127.0.0.1:15140/gateway/', + reuseExistingServer: false, + timeout: 30_000, + }, +}); diff --git a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts new file mode 100644 index 00000000..fc13b42c --- /dev/null +++ b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts @@ -0,0 +1,140 @@ +import { readFile } from 'node:fs/promises'; + +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' | 'user_a' | 'user_b'): 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 enterHwe = async (page: Page): Promise => { + const row = hweRow(page); + await expect(row).toBeVisible(); + await expect(row).not.toContainText('폐 쇄 중'); + await row.getByRole('button').click(); +}; + +const createGeneral = async ( + browser: Browser, + testInfo: TestInfo, + account: { username: string; password: 'user_a' | 'user_b'; generalName: string } +): Promise => { + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + colorScheme: 'dark', + viewport: { width: 1280, height: 900 }, + }); + const page = await context.newPage(); + await login(page, account.username, await readPassword(account.password)); + + const row = hweRow(page); + await expect(row.getByRole('button', { name: '장수생성' })).toBeVisible({ + timeout: 60_000, + }); + await expect(row.getByRole('button', { name: '장수생성' })).toBeEnabled({ + timeout: 60_000, + }); + await enterHwe(page); + + await expect(page).toHaveURL(/\/hwe\/join$/); + await expect(page.getByRole('heading', { name: '장수 생성/빙의' })).toBeVisible(); + await page.getByLabel('장수명').fill(account.generalName); + await page.getByRole('button', { name: '균형형' }).click(); + await page.locator('.form-actions').getByRole('button', { name: '장수 생성' }).click(); + + await expect(page).toHaveURL(/\/hwe\/$/); + await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); + await expect(page.locator('.error')).toHaveCount(0); + await page.screenshot({ + path: testInfo.outputPath(`${account.username}-main.png`), + fullPage: true, + }); + await context.close(); +}; + +test('admin resets and opens hwe, then two users create generals and reach main', async ({ + browser, + page, +}, testInfo) => { + test.setTimeout(360_000); + const sourceCommit = requiredEnv('SAMMO_LIFECYCLE_SOURCE_COMMIT'); + page.on('dialog', (dialog) => dialog.accept()); + + await login(page, 'guiadmin', await readPassword('admin')); + await page.getByRole('link', { name: '관리자 페이지' }).click(); + await expect(page).toHaveURL(/\/gateway\/admin$/); + await page.getByRole('link', { name: '서버 배포 · 시나리오 초기화' }).click(); + await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/); + + await page.getByTestId('profile-select').selectOption('hwe:2'); + 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('2'); + 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).toContainText('SUCCEEDED'); + await expect(profileStatus.locator('.text-emerald-400')).toHaveCount(2, { + timeout: 30_000, + }); + await page.screenshot({ + path: testInfo.outputPath('admin-reset-running.png'), + fullPage: true, + }); + + await page.getByRole('link', { name: '삼국지 모의전투 HiDCHe' }).click(); + await expect(page).toHaveURL(/\/gateway\/lobby$/); + await expect(hweRow(page).getByRole('button', { name: '장수생성' })).toBeEnabled({ + timeout: 60_000, + }); + await expect(page.getByText('서 버 선 택', { exact: true })).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath('admin-gateway-main.png'), + fullPage: true, + }); + + await createGeneral(browser, testInfo, { + username: 'guiusera', + password: 'user_a', + generalName: 'GUI장수A', + }); + await createGeneral(browser, testInfo, { + username: 'guiuserb', + password: 'user_b', + generalName: 'GUI장수B', + }); +}); diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts new file mode 100644 index 00000000..8ffd3d88 --- /dev/null +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -0,0 +1,77 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +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 installGatewayFixture = async (page: Page, roles: string[]) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-session-token', 'playwright-admin-session'); + }); + await page.route('**/gateway/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'me') { + return response({ + id: 'admin-user', + username: 'admin', + displayName: '관리자', + roles, + createdAt: '2026-07-25T00:00:00.000Z', + }); + } + if (operation === 'lobby.notice' || operation === 'admin.system.getNotice') { + return response(operation === 'lobby.notice' ? '' : { notice: '' }); + } + if ( + operation === 'lobby.profiles' || + operation === 'admin.profiles.list' || + operation === 'admin.profiles.listScenarios' || + operation === 'admin.operations.list' + ) { + return response([]); + } + if (operation === 'admin.users.getLocalAccountStatus') { + return response({ enabled: true }); + } + throw new Error(`Unhandled tRPC operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +test('bootstrap superuser can navigate from the lobby to server operations', async ({ page }) => { + await installGatewayFixture(page, ['superuser']); + + await page.goto('lobby'); + const adminLink = page.getByRole('link', { name: '관리자 페이지' }); + await expect(adminLink).toBeVisible(); + await adminLink.click(); + + await expect(page).toHaveURL(/\/gateway\/admin$/); + await expect(page.getByRole('heading', { name: '관리자 콘솔' })).toBeVisible(); + await page.getByRole('link', { name: '서버 배포 · 시나리오 초기화' }).click(); + await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/); +}); + +test('scoped administrators see the same navigation while ordinary users do not', async ({ browser }) => { + const scopedContext = await browser.newContext(); + const scopedPage = await scopedContext.newPage(); + await installGatewayFixture(scopedPage, ['admin.profiles.manage:hwe:2']); + await scopedPage.goto('lobby'); + await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible(); + await scopedContext.close(); + + const userContext = await browser.newContext(); + const userPage = await userContext.newPage(); + await installGatewayFixture(userPage, []); + await userPage.goto('lobby'); + await expect(userPage.getByRole('link', { name: '관리자 페이지' })).toHaveCount(0); + await userContext.close(); +}); diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index 95fb8c4f..a66fb705 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../. export default defineConfig({ testDir: '.', - testMatch: 'server-operations.spec.ts', + testMatch: ['server-operations.spec.ts', 'lobby-admin-navigation.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/gateway-frontend/e2e/prefix-proxy.mjs b/app/gateway-frontend/e2e/prefix-proxy.mjs new file mode 100644 index 00000000..a3125250 --- /dev/null +++ b/app/gateway-frontend/e2e/prefix-proxy.mjs @@ -0,0 +1,44 @@ +import http from 'node:http'; + +const routes = [ + { prefix: '/gateway/api', port: 15001 }, + { prefix: '/gateway', port: 15000 }, + { prefix: '/hwe/api', port: 15015 }, + { prefix: '/hwe', port: 15014 }, +]; + +const server = http.createServer((request, response) => { + const route = routes.find((candidate) => request.url?.startsWith(candidate.prefix)); + if (!route) { + response.writeHead(404); + response.end(); + return; + } + + const upstream = http.request( + { + host: '127.0.0.1', + port: route.port, + method: request.method, + path: request.url, + headers: { + ...request.headers, + host: `127.0.0.1:${route.port}`, + }, + }, + (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + } + ); + + upstream.on('error', () => { + if (!response.headersSent) { + response.writeHead(502); + } + response.end(); + }); + request.pipe(upstream); +}); + +server.listen(15140, '127.0.0.1'); diff --git a/app/gateway-frontend/package.json b/app/gateway-frontend/package.json index 73a64b02..ca30b3bc 100644 --- a/app/gateway-frontend/package.json +++ b/app/gateway-frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc pnpm build && playwright test --config e2e/playwright.config.mjs", + "test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs", "build": "vue-tsc && vite build", "preview": "vite preview", "lint": "eslint .", diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index c9df80f1..391b5a46 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -1,5 +1,5 @@