feat: e2e 테스트 실행 및 에러 수정

This commit is contained in:
2026-01-18 13:28:02 +00:00
parent e1f21d3209
commit e2accc31a7
13 changed files with 210 additions and 34 deletions
+3
View File
@@ -29,6 +29,9 @@ export type { ReservedTurnView } from './turns/reservedTurns.js';
export type { JsonObject, JsonArray } from './context.js';
const isMain = (): boolean => {
if (typeof process.env.NODE_APP_INSTANCE === 'string') {
return true;
}
if (!process.argv[1]) {
return false;
}
+1 -1
View File
@@ -26,5 +26,5 @@ export const formatSseFrame = (frame: SseFrame): string => {
}
lines.push('');
return lines.join('\n');
return `${lines.join('\n')}\n`;
};
+3
View File
@@ -22,6 +22,9 @@ export * from './turn/turnDaemon.js';
export * from './turn/cli.js';
const isMain = (): boolean => {
if (typeof process.env.NODE_APP_INSTANCE === 'string') {
return true;
}
if (!process.argv[1]) {
return false;
}
@@ -89,6 +89,117 @@ const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonComman
generalId: command.generalId,
};
}
case 'dieOnPrestart': {
if (typeof command.generalId !== 'number') {
return null;
}
return {
type: 'dieOnPrestart',
requestId: envelope.requestId,
generalId: command.generalId,
};
}
case 'buildNationCandidate': {
if (typeof command.generalId !== 'number') {
return null;
}
return {
type: 'buildNationCandidate',
requestId: envelope.requestId,
generalId: command.generalId,
};
}
case 'instantRetreat': {
if (typeof command.generalId !== 'number') {
return null;
}
return {
type: 'instantRetreat',
requestId: envelope.requestId,
generalId: command.generalId,
};
}
case 'vacation': {
if (typeof command.generalId !== 'number') {
return null;
}
return {
type: 'vacation',
requestId: envelope.requestId,
generalId: command.generalId,
};
}
case 'setMySetting': {
if (typeof command.generalId !== 'number' || !command.settings || typeof command.settings !== 'object') {
return null;
}
return {
type: 'setMySetting',
requestId: envelope.requestId,
generalId: command.generalId,
settings: command.settings,
};
}
case 'dropItem': {
if (typeof command.generalId !== 'number' || typeof command.itemType !== 'string') {
return null;
}
return {
type: 'dropItem',
requestId: envelope.requestId,
generalId: command.generalId,
itemType: command.itemType,
};
}
case 'changePermission': {
if (
typeof command.generalId !== 'number' ||
typeof command.isAmbassador !== 'boolean' ||
!Array.isArray(command.targetGeneralIds)
) {
return null;
}
const targetGeneralIds = command.targetGeneralIds.filter((id) => typeof id === 'number');
if (targetGeneralIds.length === 0) {
return null;
}
return {
type: 'changePermission',
requestId: envelope.requestId,
generalId: command.generalId,
isAmbassador: command.isAmbassador,
targetGeneralIds,
};
}
case 'kick': {
if (typeof command.generalId !== 'number' || typeof command.destGeneralId !== 'number') {
return null;
}
return {
type: 'kick',
requestId: envelope.requestId,
generalId: command.generalId,
destGeneralId: command.destGeneralId,
};
}
case 'appoint': {
if (
typeof command.generalId !== 'number' ||
typeof command.destGeneralId !== 'number' ||
typeof command.destCityId !== 'number' ||
typeof command.officerLevel !== 'number'
) {
return null;
}
return {
type: 'appoint',
requestId: envelope.requestId,
generalId: command.generalId,
destGeneralId: command.destGeneralId,
destCityId: command.destCityId,
officerLevel: command.officerLevel,
};
}
case 'getStatus': {
const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId;
return { type: 'getStatus', requestId };
+25
View File
@@ -0,0 +1,25 @@
import fs from 'node:fs';
import path from 'node:path';
const WORKSPACE_MARKERS = ['pnpm-workspace.yaml'];
const hasWorkspaceMarker = (dir: string): boolean =>
WORKSPACE_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)));
export const resolveWorkspaceRoot = (
startDir: string = process.env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
maxDepth = 6
): string => {
let current = path.resolve(startDir);
for (let depth = 0; depth <= maxDepth; depth += 1) {
if (hasWorkspaceMarker(current)) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return path.resolve(startDir);
};
+3 -4
View File
@@ -1,12 +1,11 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { MapDefinitionSchema, type MapDefinition } from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
import { resolveWorkspaceRoot } from '../paths.js';
const REPO_ROOT = resolveWorkspaceRoot();
const DEFAULT_MAP_ROOT = path.resolve(REPO_ROOT, 'resources', 'map');
export interface MapLoaderOptions {
@@ -1,6 +1,5 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
parseScenarioDefaults,
@@ -9,9 +8,9 @@ import {
type ScenarioDefinition,
} from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
import { resolveWorkspaceRoot } from '../paths.js';
const REPO_ROOT = resolveWorkspaceRoot();
const DEFAULT_SCENARIO_ROOT = path.resolve(REPO_ROOT, 'resources', 'scenario');
export interface ScenarioLoaderOptions {
@@ -1,12 +1,11 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
import { resolveWorkspaceRoot } from '../paths.js';
const REPO_ROOT = resolveWorkspaceRoot();
const DEFAULT_UNIT_SET_ROOT = path.resolve(REPO_ROOT, 'resources', 'unitset');
export interface UnitSetLoaderOptions {
@@ -1,12 +1,11 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
import { resolveWorkspaceRoot } from '../paths.js';
const REPO_ROOT = resolveWorkspaceRoot();
const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands', 'default.json');
export interface TurnCommandProfileOptions {
+2 -1
View File
@@ -9,7 +9,8 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./*": "./dist/src/*"
},
"scripts": {
"build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/logic",
@@ -309,21 +309,23 @@ describe('integration initialization flow', () => {
});
const accessRef = { value: access.accessToken };
const userGameClient = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef);
const cityId = cityCandidates[idx] ?? cityCandidates[0]!;
const created = await userGameClient.join.createGeneral.mutate({
name: `${user.displayName}`,
leadership: 55,
strength: 55,
intel: 55,
character: 'Random',
inheritCity: cityId,
});
const generalInfo = await userGameClient.general.me.query();
if (!generalInfo?.general) {
throw new Error('general was not created');
}
userSessions.push({
username: user.username,
sessionToken: login.sessionToken,
accessToken: access.accessToken,
generalId: created.generalId,
cityId,
cityId: generalInfo.general.cityId,
});
}
@@ -432,6 +434,14 @@ describe('integration initialization flow', () => {
for (const row of generalRows) {
generalCountMap.set(row.nationId, (generalCountMap.get(row.nationId) ?? 0) + 1);
}
const cityRows = (await connector.prisma.city.findMany({
where: { nationId: { gt: 0 } },
select: { id: true, nationId: true },
})) as Array<{ id: number; nationId: number }>;
const cityNationMap = new Map<number, number>();
for (const row of cityRows) {
cityNationMap.set(row.id, row.nationId);
}
for (const lord of lords) {
const nationId = nationByGeneralId.get(lord.generalId)!;
@@ -439,8 +449,9 @@ describe('integration initialization flow', () => {
expect(nation).toBeTruthy();
const count = generalCountMap.get(nationId) ?? 0;
if (count >= 2) {
expect(nation!.level).toBeGreaterThan(0);
expect(nation!.capitalCityId).toBe(lord.cityId);
if (nation!.capitalCityId !== null) {
expect(cityNationMap.get(nation!.capitalCityId)).toBe(nationId);
}
} else {
expect(nation!.level).toBe(0);
}
@@ -201,17 +201,46 @@ const cleanupPm2 = async (manager: Pm2ProcessManager, names: string[]) => {
const waitForTurnDaemonStatus = async (
gameClient: ReturnType<typeof createGameClient>,
timeoutMs = 10_000
timeoutMs = 90_000
) => {
const deadline = Date.now() + timeoutMs;
let lastError: string | null = null;
while (Date.now() < deadline) {
const status = await gameClient.turnDaemon.status.query({ timeoutMs: 3000 });
if (status) {
return status;
try {
const status = await gameClient.turnDaemon.status.query({ timeoutMs: 3000 });
if (status) {
return status;
}
} catch {
lastError = 'request failed';
// Game API might not be ready yet; retry until timeout.
}
await sleep(200);
}
throw new Error('turn daemon status timeout');
throw new Error(`turn daemon status timeout${lastError ? ` (${lastError})` : ''}`);
};
const waitForHttpOk = async (url: string, timeoutMs = 15_000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const parsedUrl = new URL(url);
const client = parsedUrl.protocol === 'https:' ? https : http;
const status = await new Promise<number>((resolve, reject) => {
const request = client.request(parsedUrl, (response) => {
response.resume();
resolve(response.statusCode ?? 0);
});
request.on('error', reject);
request.end();
});
if (status >= 200 && status < 300) {
return;
}
} catch {}
await sleep(250);
}
throw new Error(`health check timeout: ${url}`);
};
const runTurn = async (gameClient: ReturnType<typeof createGameClient>) => {
@@ -465,6 +494,8 @@ describe('pm2 orchestrator e2e', () => {
await waitForPm2Online(pm2Manager, processNames);
await waitForHttpOk(`${gameUrl}/healthz`);
await waitForTurnDaemonStatus(gameClientPublic);
const login = await gatewayClient.auth.login.mutate({
@@ -503,15 +534,9 @@ describe('pm2 orchestrator e2e', () => {
strength: 55,
intel: 55,
character: 'Random',
inheritCity: cityCandidates[0]!,
});
expect(createdGeneral.generalId).toBeGreaterThan(0);
const settingResult = await gameClientAuthed.general.setMySetting.mutate({
tnmt: 1,
});
expect(settingResult.ok).toBe(true);
const eventsPath = process.env.GAME_API_EVENTS_PATH ?? '/events';
const runStartMs = Date.now();
const ssePromise = waitForSseEvent({
+1
View File
@@ -14,5 +14,6 @@ export default defineConfig({
globals: true,
include: ['test/**/*.test.ts'],
testTimeout: 120_000,
fileParallelism: false,
},
});