1import { chromium, type Browser, type BrowserContext, type Page } from 'playwright' 2import * as _ from 'lodash-es' 7const debugLiveHarness = process.env.DEBUG_LIVE_HARNESS === '1' 8const debugLog = (...args: unknown[]) => debugLiveHarness && console.log(...args) 13 context: BrowserContext 15 gameState: Record<string, unknown> | null 18type LiveHarnessOpts = { 25 * Creates a Playwright-based test harness for live URL testing. 26 * Launches multiple browser contexts (one per player) and orchestrates UI interactions. 28 * Implements the same interface as testHarnessAI.mjs for compatibility with existing scenarios. 30export const createLiveHarness = async ({ baseUrl, playerCount = 4, gameRoom }: LiveHarnessOpts) => { 32 const browser = await chromium.launch({ headless: true }) 34 // Create N browser contexts (one per player) 35 const playerContexts = await Promise.all( 36 _.times(playerCount, async (playerIdx) => { 37 const context = await browser.newContext() 38 const page = await context.newPage() 39 const sessId = `live-player-${playerIdx + 1}-${Date.now()}` 40 const playerAlias = `player${playerIdx + 1}` 41 return { sessId, playerAlias, context, page, gameState: null } 45 // Setup GQL response interception for each page to track game state 46 for (const pc of playerContexts) { 47 pc.page.on('response', async (resp) => { 48 if (resp.url().includes(apiGqlPath)) { 50 const body = await resp.json() 51 // Track game state from getOldGameState or gqGame queries 52 const gqGameState = body?.data?.gqGame?.gqGameState || body?.data?.getOldGameState 54 pc.gameState = gqGameState 57 // Ignore non-JSON responses 63 // Navigate all players to game URL and wait for initial GQL response 64 // Game lobby is at /{gameRoom} 65 const gameUrl = `${baseUrl}/${gameRoom}` 66 const urlObj = new URL(gameUrl) 67 debugLog({ navigatingTo: gameUrl, playerCount }) 68 await Promise.all(playerContexts.map(async (pc) => { 69 // Pre-set gameShardCook cookie BEFORE navigation to ensure consistent pod routing 70 // Without this, each browser hits a random pod and creates separate games 71 await pc.context.addCookies([{ 72 name: 'gameShardCook', 74 domain: urlObj.hostname, 77 await pc.page.goto(gameUrl, { waitUntil: 'load' }) 78 debugLog({ playerLoaded: pc.playerAlias, hasGameState: !!pc.gameState }) 79 // Wait for initial game state to be captured (max 5s) 80 for (let i = 0; i < 50 && !pc.gameState; i++) { 81 await pc.page.waitForTimeout(100) 83 debugLog({ playerReady: pc.playerAlias, hasGameState: !!pc.gameState }) 86 // Track which browser contexts have been assigned to agents 87 const assignedBrowserSessIds = new Set<string>() 88 // Map agent sessId -> playerContext 89 const agentSessIdToContext: Record<string, PlayerContext> = {} 91 const applyAction = async ({ sessId, playerAction, playerAlias: actionAlias, ...params }: { 92 sessId: string, playerAction: string, playerAlias?: string, [key: string]: unknown 94 // Find player context by agent sessId 95 let pc: PlayerContext | undefined = agentSessIdToContext[sessId] 98 // For setPlayerAlias, find an unassigned browser context 99 if (playerAction === 'setPlayerAlias') { 100 pc = playerContexts.find(p => !assignedBrowserSessIds.has(p.sessId)) 101 debugLog({ setPlayerAlias_findingContext: { sessId, actionAlias, foundPc: !!pc } }) 103 assignedBrowserSessIds.add(pc.sessId) 104 agentSessIdToContext[sessId] = pc 109 debugLog({ noContextFound: { sessId, playerAction, actionAlias } }) 110 return { ok: false, error: new Error(`Unknown sessId: ${sessId}`) } 114 debugLog({ applyingAction: playerAction, sessId, playerAlias: actionAlias }) 115 const result = await executeUiAction(pc.page, { playerAction, playerAlias: actionAlias, ...params }) 116 // Wait a bit for state to propagate 117 await pc.page.waitForTimeout(500) 118 debugLog({ actionResult: playerAction, ok: result.ok }) 121 debugLog({ actionError: playerAction, error: (err as Error).message }) 122 return { ok: false, error: err } 126 // eslint-disable-next-line @typescript-eslint/no-explicit-any 127 type AnyGameState = Record<string, any> 129 const getLatestGameState = (): AnyGameState | null => { 130 const baseGs = playerContexts.find(pc => pc.gameState)?.gameState 131 if (!baseGs) return null 133 // Build playerStates keyed by agent sessId for agent compatibility 134 const playerStates: AnyGameState = {} 135 for (const [agentSessId, pc] of Object.entries(agentSessIdToContext)) { 136 const myState = pc.gameState?.myState as AnyGameState | undefined 137 if (myState?.publicState?.playerAlias) { 138 playerStates[agentSessId] = { 139 publicState: myState.publicState, 140 assignedStackName: myState.assignedStackName, 141 taskPrompt: myState.taskPrompt, 142 hasBeenKicked: false, 147 return { ...(baseGs as object), playerStates, hiddenState: { paperStacks: {} } } 152 getState: getLatestGameState, 153 getActivePlayers: () => { 154 const gs = getLatestGameState() 155 if (!gs?.playerStates) return [] 156 return _.map(_.filter(_.toPairs(gs.playerStates) as [string, AnyGameState][], 157 ([, ps]) => !ps.hasBeenKicked), ([sessId, ps]) => ({ sessId, ...ps as object })) 159 getPlayer: (sessId: string) => { 160 const gs = getLatestGameState() 161 return gs?.playerStates?.[sessId] 163 getPaperStacks: () => { 164 const gs = getLatestGameState() 165 return gs?.hiddenState?.paperStacks || {} 167 cleanup: async () => { 168 await browser.close() 170 // Expose for debugging 171 _playerContexts: playerContexts,