test: harden exhaustive compatibility suites

This commit is contained in:
2026-07-30 16:19:30 +00:00
parent 4ebd3fd39b
commit a9dbb3fd4d
28 changed files with 454 additions and 97 deletions
@@ -76,10 +76,15 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath:
if (resolvedFixture !== fixtureRoot && !resolvedFixture.startsWith(`${fixtureRoot}${path.sep}`)) {
throw new Error(`Reference turn fixture must be under ${fixtureRoot}`);
}
const stdout = execFileSync('./scripts/run-turn-differential-case.sh', [resolvedFixture], {
const runner = process.env.TURN_DIFFERENTIAL_CASE_SCRIPT ?? './scripts/run-turn-differential-case.sh';
const stdout = execFileSync(runner, [resolvedFixture], {
cwd: stackDirectory,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
},
});
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
};
@@ -89,7 +94,7 @@ export const runReferenceTurnCommandTraceRequest = (
request: Record<string, unknown>
): CanonicalTurnCommandTrace => {
const stackDirectory = path.join(workspaceRoot, 'docker_compose_files/reference');
const runner = process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT ?? './scripts/run-turn-differential-case.sh';
const runner = process.env.TURN_DIFFERENTIAL_CASE_SCRIPT ?? './scripts/run-turn-differential-case.sh';
const stdout = execFileSync(runner, ['-'], {
cwd: stackDirectory,
input: JSON.stringify(request),
@@ -276,6 +276,12 @@ describe('auction integration flow', () => {
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
status: 'RUNNING',
});
await gatewayClient.admin.profiles.updateMeta.mutate({
profileName: 'che:908',
patch: {
localAccountGeneralCreationGraceDays: 7,
},
});
await gatewayClient.admin.profiles.installNow.mutate({
profileName: 'che:908',
@@ -200,7 +200,8 @@ const assertTraceParity = (
}
};
const workspaceRoot = findWorkspaceRoot(process.cwd());
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findWorkspaceRoot(process.cwd());
const describeWithReference = workspaceRoot ? describe : describe.skip;
describeWithReference('ref ↔ core2026 battle differential', () => {
@@ -240,6 +240,12 @@ describe('integration initialization flow', () => {
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
status: 'RUNNING',
});
await gatewayClient.admin.profiles.updateMeta.mutate({
profileName: 'che:2',
patch: {
localAccountGeneralCreationGraceDays: 7,
},
});
await gatewayClient.admin.profiles.installNow.mutate({
profileName: 'che:2',
@@ -358,7 +364,7 @@ describe('integration initialization flow', () => {
action: 'che_건국',
args: {
nationName: `TestNation${idx + 1}`,
nationType: 'che_def',
nationType: 'che_명가',
colorType: idx,
},
expectedRevision: queueRevision,
@@ -462,10 +468,9 @@ describe('integration initialization flow', () => {
const nation = nationRows.find((row) => row.id === nationId);
expect(nation).toBeTruthy();
const count = generalCountMap.get(nationId) ?? 0;
if (count >= 2) {
if (nation!.capitalCityId !== null) {
expect(cityNationMap.get(nation!.capitalCityId)).toBe(nationId);
}
expect(count).toBeGreaterThanOrEqual(1);
if ((nation!.capitalCityId ?? 0) > 0) {
expect(cityNationMap.get(nation!.capitalCityId!)).toBe(nationId);
} else {
expect(nation!.level).toBe(0);
}
@@ -99,7 +99,7 @@ integration('legacy instant diplomacy responses', () => {
expect(addedLogs(trace).map((log) => [log.generalId, log.category])).toEqual([
[1, 'history'],
[1, 'action'],
[0, 'history'],
[0, 'summary'],
[2, 'history'],
[2, 'action'],
]);
@@ -130,7 +130,7 @@ integration('legacy instant diplomacy responses', () => {
expect(addedLogs(trace).map((log) => [log.generalId, log.category])).toEqual([
[1, 'history'],
[1, 'action'],
[0, 'history'],
[0, 'summary'],
[2, 'history'],
[2, 'action'],
]);
@@ -2,6 +2,7 @@ import path from 'node:path';
import fs from 'node:fs/promises';
import http from 'node:http';
import https from 'node:https';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
@@ -173,8 +174,19 @@ const createGameClient = (baseUrl: string, trpcPath: string, accessTokenRef: { v
],
});
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
const buildProcessName = (
profileName: string,
role: 'api' | 'daemon' | 'auction' | 'battle' | 'tournament'
): string => {
const roleName = {
api: 'game-api',
daemon: 'turn-daemon',
auction: 'auction-worker',
battle: 'battle-sim-worker',
tournament: 'tournament-worker',
}[role];
return `sammo:${profileName}:${roleName}`;
};
const waitForPm2Online = async (manager: Pm2ProcessManager, names: string[], timeoutMs = 30_000) => {
const deadline = Date.now() + timeoutMs;
@@ -204,6 +216,55 @@ const cleanupPm2 = async (manager: Pm2ProcessManager, names: string[]) => {
}
};
const processIsAlive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
const stopDedicatedPm2Daemon = async (pm2Home: string): Promise<void> => {
let daemonPid: number | null = null;
try {
const rawPid = await fs.readFile(path.join(pm2Home, 'pm2.pid'), 'utf8');
const parsedPid = Number(rawPid.trim());
if (Number.isSafeInteger(parsedPid) && parsedPid > 1) {
daemonPid = parsedPid;
}
} catch {
// PM2 might already have removed its pid file.
}
await execCommand('pnpm', ['exec', 'pm2', 'kill'], {
...process.env,
PM2_HOME: pm2Home,
}).catch(() => undefined);
if (daemonPid === null || !processIsAlive(daemonPid)) {
return;
}
for (let attempt = 0; attempt < 50 && processIsAlive(daemonPid); attempt += 1) {
await sleep(100);
}
if (!processIsAlive(daemonPid)) {
return;
}
const commandLine = (await fs.readFile(`/proc/${daemonPid}/cmdline`, 'utf8')).replaceAll('\0', ' ');
if (!commandLine.includes('PM2') || !commandLine.includes(pm2Home)) {
throw new Error(`Refusing to stop PID ${daemonPid}: it is not the dedicated PM2 daemon for ${pm2Home}`);
}
process.kill(daemonPid, 'SIGTERM');
for (let attempt = 0; attempt < 50 && processIsAlive(daemonPid); attempt += 1) {
await sleep(100);
}
if (processIsAlive(daemonPid)) {
throw new Error(`Dedicated PM2 daemon did not stop: PID ${daemonPid}, PM2_HOME ${pm2Home}`);
}
};
const waitForTurnDaemonStatus = async (gameClient: ReturnType<typeof createGameClient>, timeoutMs = 90_000) => {
const deadline = Date.now() + timeoutMs;
let lastError: string | null = null;
@@ -399,11 +460,16 @@ describe('pm2 orchestrator e2e', () => {
let profileName = 'che:2';
let apiPort = 14000;
let processNames: string[] = [];
let pm2Home: string | null = null;
let previousPm2Home: string | undefined;
beforeAll(async () => {
await loadEnv();
process.chdir(workspaceRoot);
await ensureBuildArtifacts();
previousPm2Home = process.env.PM2_HOME;
pm2Home = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-pm2-e2e-'));
process.env.PM2_HOME = pm2Home;
profile = process.env.PROFILE ?? 'che';
scenario = process.env.SCENARIO ?? '2';
@@ -416,6 +482,8 @@ describe('pm2 orchestrator e2e', () => {
processNames = [
buildProcessName(profileName, 'api'),
buildProcessName(profileName, 'daemon'),
buildProcessName(profileName, 'auction'),
buildProcessName(profileName, 'battle'),
buildProcessName(profileName, 'tournament'),
];
@@ -433,11 +501,23 @@ describe('pm2 orchestrator e2e', () => {
}, 60_000);
afterAll(async () => {
if (pm2Manager) {
await cleanupPm2(pm2Manager, processNames);
}
if (gatewayServer) {
await gatewayServer.app.close();
try {
if (pm2Manager) {
await cleanupPm2(pm2Manager, processNames);
}
if (gatewayServer) {
await gatewayServer.app.close();
}
if (pm2Home) {
await stopDedicatedPm2Daemon(pm2Home);
await fs.rm(pm2Home, { recursive: true, force: true });
}
} finally {
if (previousPm2Home === undefined) {
delete process.env.PM2_HOME;
} else {
process.env.PM2_HOME = previousPm2Home;
}
}
}, 30_000);
@@ -456,7 +536,7 @@ describe('pm2 orchestrator e2e', () => {
const adminGameAccessRef: { value?: string } = {};
const accessTokenRef: { value?: string } = {};
const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSessionRef);
const gameTrpcPath = process.env.GAME_TRPC_PATH ?? process.env.TRPC_PATH ?? '/trpc';
const gameTrpcPath = `/${profile}/api/trpc`;
const gameClientPublic = createGameClient(gameUrl, gameTrpcPath, { value: undefined });
const gameClientAdmin = createGameClient(gameUrl, gameTrpcPath, adminGameAccessRef);
const gameClientAuthed = createGameClient(gameUrl, gameTrpcPath, accessTokenRef);
@@ -482,6 +562,12 @@ describe('pm2 orchestrator e2e', () => {
apiPort,
status: 'RUNNING',
});
await gatewayClient.admin.profiles.updateMeta.mutate({
profileName,
patch: {
localAccountGeneralCreationGraceDays: 7,
},
});
await gatewayClient.admin.profiles.installNow.mutate({
profileName,
@@ -556,7 +642,7 @@ describe('pm2 orchestrator e2e', () => {
});
expect(createdGeneral.generalId).toBeGreaterThan(0);
const eventsPath = process.env.GAME_API_EVENTS_PATH ?? '/events';
const eventsPath = `/${profile}/api/events`;
const runStartMs = Date.now();
const ssePromise = waitForSseEvent({
url: `${gameUrl}${eventsPath}`,
@@ -204,6 +204,12 @@ describe('actual tournament lifecycle', () => {
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
status: 'RUNNING',
});
await gatewayClient.admin.profiles.updateMeta.mutate({
profileName: 'che:908',
patch: {
localAccountGeneralCreationGraceDays: 7,
},
});
await gatewayClient.admin.profiles.installNow.mutate({
profileName: 'che:908',
install: {
@@ -26,7 +26,8 @@ integration('legacy command trace runner', () => {
expect.objectContaining({ fromNationId: 1, toNationId: 2, state: 1, term: 24 }),
expect.objectContaining({ fromNationId: 2, toNationId: 1, state: 1, term: 24 }),
]);
expect(trace.after.messages).toHaveLength(2);
const addedMessages = trace.after.messages.slice(trace.before.messages.length);
expect(addedMessages).toHaveLength(2);
});
it('runs live sortie through conquest and nation collapse', () => {
@@ -5,7 +5,8 @@ import {
readReferenceDatabaseSnapshot,
} from '../src/turn-differential/referenceSnapshot.js';
const workspaceRoot = findTurnDifferentialWorkspaceRoot(process.cwd());
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
integration('legacy turn state snapshot adapter', () => {