🌳
pt0/gamesapp/testsF/liveHarnessAI.mts
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)
10type PlayerContext = {
11 sessId: string
12 playerAlias: string
13 context: BrowserContext
14 page: Page
15 gameState: Record<string, unknown> | null
18type LiveHarnessOpts = {
19 baseUrl: string
20 playerCount?: number
21 gameRoom: string
24/**
25 * Creates a Playwright-based test harness for live URL testing.
26 * Launches multiple browser contexts (one per player) and orchestrates UI interactions.
27 *
28 * Implements the same interface as testHarnessAI.mjs for compatibility with existing scenarios.
29 */
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 }
42 })
43 )
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)) {
49 try {
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
53 if (gqGameState) {
54 pc.gameState = gqGameState
55 }
56 } catch {
57 // Ignore non-JSON responses
58 }
59 }
60 })
61 }
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',
73 value: gameRoom,
74 domain: urlObj.hostname,
75 path: '/',
76 }])
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)
82 }
83 debugLog({ playerReady: pc.playerAlias, hasGameState: !!pc.gameState })
84 }))
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
93 }) => {
94 // Find player context by agent sessId
95 let pc: PlayerContext | undefined = agentSessIdToContext[sessId]
97 if (!pc) {
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 } })
102 if (pc) {
103 assignedBrowserSessIds.add(pc.sessId)
104 agentSessIdToContext[sessId] = pc
105 }
106 }
107 }
108 if (!pc) {
109 debugLog({ noContextFound: { sessId, playerAction, actionAlias } })
110 return { ok: false, error: new Error(`Unknown sessId: ${sessId}`) }
111 }
113 try {
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 })
119 return result
120 } catch (err) {
121 debugLog({ actionError: playerAction, error: (err as Error).message })
122 return { ok: false, error: err }
123 }
124 }
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,
143 }
144 }
145 }
147 return { ...(baseGs as object), playerStates, hiddenState: { paperStacks: {} } }
148 }
150 return {
151 applyAction,
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 }))
158 },
159 getPlayer: (sessId: string) => {
160 const gs = getLatestGameState()
161 return gs?.playerStates?.[sessId]
162 },
163 getPaperStacks: () => {
164 const gs = getLatestGameState()
165 return gs?.hiddenState?.paperStacks || {}
166 },
167 cleanup: async () => {
168 await browser.close()
169 },
170 // Expose for debugging
171 _playerContexts: playerContexts,
172 _browser: browser,
173 }