Add full HWE lifecycle browser verification
This commit is contained in:
@@ -1,22 +1,21 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
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 => {
|
export const resolveWorkspaceRoot = (startDir: string, maxDepth = 5): string => {
|
||||||
let current = path.resolve(startDir);
|
let current = path.resolve(startDir);
|
||||||
|
let packageRoot: string | null = null;
|
||||||
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
||||||
if (hasWorkspaceMarker(current)) {
|
if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
if (!packageRoot && fs.existsSync(path.join(current, 'package.json'))) {
|
||||||
|
packageRoot = current;
|
||||||
|
}
|
||||||
const parent = path.dirname(current);
|
const parent = path.dirname(current);
|
||||||
if (parent === current) {
|
if (parent === current) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
current = parent;
|
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 { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '@sammo-ts/game-engine';
|
||||||
import { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic';
|
import { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic';
|
||||||
|
import { resolveWorkspaceRoot } from '../orchestrator/workspaceRoot.js';
|
||||||
|
|
||||||
export interface ScenarioNationPreview {
|
export interface ScenarioNationPreview {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -34,7 +35,7 @@ const SCENARIO_ROOT = path.join('resources', 'scenario');
|
|||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
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 previewCache = new Map<string, { loadedAt: number; data: ScenarioPreview[] }>();
|
||||||
const defaultsCache = new Map<string, ScenarioDefaults>();
|
const defaultsCache = new Map<string, ScenarioDefaults>();
|
||||||
|
|||||||
@@ -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,124 @@
|
|||||||
|
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: '훼섭' });
|
||||||
|
|
||||||
|
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).toContainText('RUNNING');
|
||||||
|
await expect(row.getByRole('button', { name: '장수생성' })).toBeVisible();
|
||||||
|
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');
|
||||||
|
await page.getByTestId('request-reset').click();
|
||||||
|
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('operations-table')).toContainText('SUCCEEDED', {
|
||||||
|
timeout: 300_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText('RUNNING', { exact: true })).toHaveCount(3, {
|
||||||
|
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)).toContainText('RUNNING');
|
||||||
|
await enterHwe(page);
|
||||||
|
await expect(page).toHaveURL(/\/hwe\/$/);
|
||||||
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath('admin-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,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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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: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",
|
"build": "vue-tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: ['dev-sam-e2e.hided.net'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user