2 * Integration test for multi-replica scaling with pod deletion. 5 * 1. Two games run in parallel on potentially different pods 6 * 2. Pod deletion happens mid-test 7 * 3. Both games complete successfully despite disruption 15import { sql } from 'kysely' 21type ScalingCtxOpts = { cluster_name: string, name: string } 22type K8sPod = { metadata: { name: string }, status: { containerStatuses?: Array<{ ready: boolean }> } } 24// Helper context passed through to avoid module-level mutable state 25const createScalingCtx = ({ cluster_name, name }: ScalingCtxOpts) => { 26 const logVerbose = (msg: string) => { console.log(chalkGray(msg)) } 28 const kubeCli = async (argsStr: string) => { 29 const args = argsStr.split(' ').filter(Boolean) 30 return noOutCmdCtx.run({}, () => eptKubeCli([cluster_name, ...args])) 33 const deleteOnePod = async () => { 34 const result = await kubeCli(`get pods -l name=${name} -o jsonpath={.items[0].metadata.name}`) 35 const podName = result?.stdout?.trim() 37 logVerbose(` Deleting pod: ${podName}`) 38 await kubeCli(`delete pod ${podName}`) 42 const getReadyPods = async (): Promise<K8sPod[]> => { 43 const result = await kubeCli(`get pods -l name=${name} --field-selector=status.phase=Running -o json`) 44 const pods = JSON.parse(result?.stdout || '{"items":[]}') as { items: K8sPod[] } 45 return pods.items.filter((p: K8sPod) => p.status.containerStatuses?.every((c) => c.ready)) 48 const waitForPodCount = async (count: number, timeoutMs = 90_000) => { 49 const start = Date.now() 50 while (Date.now() - start < timeoutMs) { 51 const readyPods = await getReadyPods() 52 if (readyPods.length === count) { 60 return { logVerbose, kubeCli, deleteOnePod, getReadyPods, waitForPodCount } 63const getOrCreateTestOwner = async (suffix: string) => { 66 const existing = await db.selectFrom('users') 67 .where('email', '=', testEmail) 70 if (existing) return existing.id 72 const inserted = await db.insertInto('users') 74 email: testEmail, updated_at: sql`now()`, 75 data: {} as never, faucet_ips: {} as never, 82const ensureTestRoomExists = async (roomLabel: string) => { 84 const existing = await db.selectFrom('gamerooms') 85 .where('room_label', '=', roomLabel) 90 // Use different owners for each room to avoid constraint violations 91 const ownerUserId = await getOrCreateTestOwner(roomLabel) 92 // Deactivate any existing active rooms for this test owner (unique constraint) 93 await db.updateTable('gamerooms') 94 .set({is_gameactive: false}) 95 .where('owner_userid', '=', ownerUserId) 96 .where('is_gameactive', '=', true) 98 await db.insertInto('gamerooms') 99 .values({room_label: roomLabel, owner_userid: ownerUserId, jdata: {}, updated_at: sql`now()`}) 107 onProgress?: (info: { gameRoom: string, round: number }) => void 110// eslint-disable-next-line @typescript-eslint/no-explicit-any 113const runGameToCompletion = async ({ baseUrl, gameRoom, playerCount, onProgress }: RunGameOpts) => { 114 await ensureTestRoomExists(gameRoom) 116 const agents = createAgentTeam({ count: playerCount, seed: gameRoom, type: 'random' }) 121 maxRounds: 30, // 4-player game finishes in ~8-10 rounds 122 onRound: ({ round }: { round: number }) => onProgress?.({ gameRoom, round }) 125 await harness.cleanup() 126 return { gameRoom, ...result } 129type ScalingIntegrationOpts = { baseUrl?: string, cluster_name?: string, name?: string } 131export const scalingIntegration = async ({ baseUrl, cluster_name, name }: ScalingIntegrationOpts = {}) => { 136 // Create context with helpers (avoids module-level mutable state) 137 const ctx = createScalingCtx({ cluster_name, name }) 138 const { logVerbose, deleteOnePod, getReadyPods, waitForPodCount } = ctx 140 const timestamp = Date.now() 141 const gameA = `scale-a-${timestamp}` 142 const gameB = `scale-b-${timestamp}` 143 const playerCount = 4 145 // Check if we have enough replicas to run the test 146 const initialPods = await getReadyPods() 147 if (initialPods.length < 2) { 151 message: `Skipped: only ${initialPods.length} replica(s) running (need 2)` 155 logVerbose(`\n Games: ${gameA}, ${gameB}`) 156 logVerbose(` Players per game: ${playerCount}`) 158 // Verify we have 2 replicas 159 const pods = await waitForPodCount(2) 160 logVerbose(` Pods ready: ${pods?.map((p: K8sPod) => p.metadata.name).join(', ')}`) 162 // Track progress for both games 163 const progress = { a: 0, b: 0 } 164 const updateProgress = ({ gameRoom, round }: { gameRoom: string, round: number }) => { 165 if (gameRoom.includes('-a-')) progress.a = round 166 else progress.b = round 167 process.stdout.write(`\r Progress: A=${progress.a} B=${progress.b}`) 170 // Schedule pod deletion after 5 seconds (should be mid-game for at least one) 171 const deletionPromise = (async () => { 173 logVerbose('\n Deleting one pod mid-test...') 174 const deleted = await deleteOnePod() 175 logVerbose(` Waiting for replacement...`) 176 await waitForPodCount(2) 177 logVerbose(` Replacement ready, continuing...`) 181 // Run both games to completion in parallel 182 const [resultA, resultB, deletedPod] = await Promise.all([ 183 runGameToCompletion({ baseUrl, gameRoom: gameA, playerCount, onProgress: updateProgress }), 184 runGameToCompletion({ baseUrl, gameRoom: gameB, playerCount, onProgress: updateProgress }), 189 logVerbose(` Game A: ${resultA.isGameFin ? 'finished' : 'NOT finished'} in ${resultA.rounds} rounds`) 190 logVerbose(` Game B: ${resultB.isGameFin ? 'finished' : 'NOT finished'} in ${resultB.rounds} rounds`) 191 logVerbose(` Pod deleted: ${deletedPod}`) 194 const errors: string[] = [] 195 if (!resultA.isGameFin) errors.push(`Game A did not finish (rounds: ${resultA.rounds})`) 196 if (!resultB.isGameFin) errors.push(`Game B did not finish (rounds: ${resultB.rounds})`) 198 if (errors.length > 0) { 199 return { passed: false, message: errors.join('; ') } 204 message: `Both games completed (A: ${resultA.rounds}, B: ${resultB.rounds} rounds) with pod deletion mid-test`