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
@@ -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}`,