merge: verify HWE GUI lifecycle
This commit is contained in:
@@ -84,6 +84,9 @@ export const createGameApiServer = async () => {
|
||||
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
routerOptions: {
|
||||
maxParamLength: 2048,
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
|
||||
@@ -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<void> {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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<string, { loadedAt: number; data: ScenarioPreview[] }>();
|
||||
const defaultsCache = new Map<string, ScenarioDefaults>();
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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<string> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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',
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
@@ -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 .",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { inferRouterOutputs } from '@trpc/server';
|
||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||
@@ -28,6 +28,16 @@ const profiles = ref<LobbyProfile[]>([]);
|
||||
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
||||
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
||||
const entryLoading = ref<Record<string, boolean>>({});
|
||||
const canAccessAdmin = computed(
|
||||
() =>
|
||||
me.value?.roles.some(
|
||||
(role) =>
|
||||
role === 'superuser' ||
|
||||
role === 'admin' ||
|
||||
role === 'admin.superuser' ||
|
||||
role.startsWith('admin.')
|
||||
) ?? false
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -353,12 +363,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
>
|
||||
로 그 아 웃
|
||||
</button>
|
||||
<button
|
||||
v-if="me?.roles?.includes('admin')"
|
||||
<RouterLink
|
||||
v-if="canAccessAdmin"
|
||||
to="/admin"
|
||||
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
||||
>
|
||||
관리자 페이지
|
||||
</button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -351,7 +351,11 @@ onBeforeUnmount(() => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedProfile" class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div
|
||||
v-if="selectedProfile"
|
||||
class="grid grid-cols-2 gap-3 text-sm"
|
||||
data-testid="selected-profile-status"
|
||||
>
|
||||
<div class="rounded bg-zinc-950 p-3">
|
||||
<div class="text-xs text-zinc-500">목표 상태</div>
|
||||
<div class="mt-1 font-semibold">{{ selectedProfile.status }}</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['dev-sam-e2e.hided.net'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -115,3 +115,47 @@ Optional checks:
|
||||
- PM2 is global; use unique `profileName` per test run to avoid collisions.
|
||||
- Orchestrator starts binaries from `dist/` under `GATEWAY_WORKSPACE_ROOT`.
|
||||
If build artifacts are missing, PM2 will start and immediately exit.
|
||||
|
||||
## HWE GUI lifecycle
|
||||
|
||||
`app/gateway-frontend/e2e/hwe-lifecycle.spec.ts` verifies the operational
|
||||
browser flow with one administrator and two independent user contexts:
|
||||
|
||||
1. The administrator logs in, selects `hwe:2`, loads scenario 2 from a fixed
|
||||
commit, requests a reset, and waits for that exact operation to succeed.
|
||||
2. The administrator returns to the gateway main page and sees the open HWE
|
||||
action.
|
||||
3. Each user logs in with a separate Chromium context, sees the same open HWE
|
||||
row, creates a general, and reaches the HWE main dashboard without an error.
|
||||
|
||||
The test uses an ignored secret directory. It reads `admin_password`,
|
||||
`user_a_password`, and `user_b_password` from
|
||||
`SAMMO_LIFECYCLE_SECRET_ROOT`; passwords must not be passed on the command
|
||||
line. `SAMMO_LIFECYCLE_SOURCE_COMMIT` must be a full commit SHA.
|
||||
|
||||
Build the gateway frontend with the public prefix contract before starting its
|
||||
preview server:
|
||||
|
||||
```bash
|
||||
VITE_APP_BASE_PATH=/gateway \
|
||||
VITE_GATEWAY_API_URL=/gateway/api/trpc \
|
||||
VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc' \
|
||||
VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' \
|
||||
pnpm --filter @sammo-ts/gateway-frontend build
|
||||
```
|
||||
|
||||
Build the HWE frontend with `VITE_APP_BASE_PATH=/hwe`,
|
||||
`VITE_GAME_API_URL=/hwe/api/trpc`, and
|
||||
`VITE_GAME_SSE_URL=/hwe/api/events`. Start the isolated gateway API,
|
||||
orchestrator, both previews, Postgres, and Redis, then run:
|
||||
|
||||
```bash
|
||||
SAMMO_LIFECYCLE_SECRET_ROOT=/path/to/ignored/secrets \
|
||||
SAMMO_LIFECYCLE_SOURCE_COMMIT="$(git rev-parse HEAD)" \
|
||||
pnpm --filter @sammo-ts/gateway-frontend test:e2e:hwe-lifecycle
|
||||
```
|
||||
|
||||
The Playwright web server is a local prefix-preserving proxy on port `15140`.
|
||||
It mirrors the Caddy route contract while keeping all navigation on one
|
||||
origin. Passing the test means the actual reset/build/seed/PM2 process path and
|
||||
both user creation flows completed; it is not a mocked API test.
|
||||
|
||||
Reference in New Issue
Block a user