import { Browser, Page, chromium, BrowserContext } from 'playwright'; import { ICodenamesGameAPI, BoardState, Clue, Team, BoardWord } from '../shared/types'; import { Selectors } from './selectors'; const BASE_URL = process.env.CODENAMES_BASE_URL; const ROOM_CODE = process.env.CODENAMES_TEST_ROOM_CODE; function getRoomUrl() { if (!BASE_URL || !ROOM_CODE) throw new Error('CODENAMES_BASE_URL and CODENAMES_TEST_ROOM_CODE must be set'); return `${BASE_URL}${ROOM_CODE}`; } // Utility function to map color string to Team enum function mapColorToTeam(color: string | null): Team | null { switch (color) { case 'red': return Team.Red; case 'blue': return Team.Blue; case 'gray': return Team.Neutral; case 'black': return Team.Assassin; default: return null; } } export class CodenamesGameHandler implements ICodenamesGameAPI { private browser: Browser; private page: Page; private context: BrowserContext | null = null; private constructor(page: Page, browser?: Browser, context?: BrowserContext) { this.page = page; if (context) { this.context = context; this.browser = context.browser()!; } else if (browser) { this.browser = browser; } else { throw new Error("Either Browser or BrowserContext must be provided"); } } static async createInContext(context: BrowserContext): Promise { const page = await context.newPage(); await page.goto(getRoomUrl()); return new CodenamesGameHandler(page, undefined, context); } async joinRoom(): Promise { // Wait for loading to disappear try { await this.page.waitForSelector(Selectors.loadingSelector, { state: 'hidden', timeout: 30000 }); } catch (e) { await this._handleScreenshot('screenshots/joinRoom_loading_timeout.png', '[joinRoom] Loading screen did not disappear after 30s'); throw e; } const nicknameInput = this.page.locator(Selectors.nicknameInput); if (await nicknameInput.isVisible({ timeout: 10000 }).catch(async () => { await this._handleScreenshot('screenshots/joinRoom_no_nickname.png', '[joinRoom] Nickname input not visible, skipping join'); return false; })) { await nicknameInput.fill(`TestPlayer${Math.floor(Math.random() * 10000)}`); const joinButton = this.page.locator(Selectors.joinRoomButton); await joinButton.waitFor({ timeout: 10000 }); console.log(`>>> Clicking joinButton`); await joinButton.click(); } } async leaveRoom(): Promise { const profileMenuButton = this.page.locator(Selectors.profileMenuButton); await profileMenuButton.waitFor({ timeout: 5000 }); await profileMenuButton.click(); const leaveRoomButton = this.page.locator(Selectors.leaveRoomButton); await leaveRoomButton.waitFor({ timeout: 5000 }); const prevUrl = this.page.url(); await leaveRoomButton.click(); await this.page.waitForFunction((oldUrl) => window.location.href !== oldUrl, prevUrl, { timeout: 5000 }); } async joinAsSpymaster(team: Team): Promise { console.log(`>>> Entering joinAsSpymaster for team: ${team === Team.Red ? 'Red' : 'Blue'}`); const teamBoardSelector = team === Team.Red ? Selectors.teamBoardRed : Selectors.teamBoardBlue; const joinSpymasterButtonSelector = team === Team.Red ? Selectors.joinRedSpymasterButton : Selectors.joinBlueSpymasterButton; try { // Wait for the team board (redundant if button check passed, but safe) await this.page.waitForSelector(teamBoardSelector, { state: 'visible', timeout: 5000 }); } catch (e) { // This specific catch might be less likely now, but keep for safety await this._handleScreenshot('screenshots/joinSpymaster_teamBoard_timeout_alt.png', `[joinAsSpymaster] Team board (${teamBoardSelector}) did not become visible even after button check`); throw e; } // First, check if the join button is even visible. If not, assume role is taken/player already joined. await new Promise(resolve => setTimeout(resolve, 1000)); const isJoinButtonVisible = await this.page.locator(joinSpymasterButtonSelector).isVisible({ timeout: 20000 }).catch(() => false); if (!isJoinButtonVisible) { // console.log(`Join Spymaster button (${joinSpymasterButtonSelector}) is not visible. Assuming already joined or role unavailable.`); await this._handleScreenshot('screenshots/joinSpymaster_no_button.png', `[joinAsSpymaster] Join button (${joinSpymasterButtonSelector}) not visible`); return; // Exit early } console.log(`Join Spymaster button (${joinSpymasterButtonSelector}) is visible. Attempting to join.`); const joinSpymasterButton = this.page.locator(joinSpymasterButtonSelector); try { console.log(`>>> Clicking joinSpymasterButton`); await joinSpymasterButton.click(); } catch (e) { await this._handleScreenshot('screenshots/joinSpymaster_click_or_hidden_timeout_alt.png', `[joinAsSpymaster] Error clicking button (${joinSpymasterButtonSelector})`); throw e; } await joinSpymasterButton.waitFor({ state: 'hidden', timeout: 5000 }); } async getBoardState(): Promise { // Only extract word and color in the browser context const rawCards = await this.page.$$eval( Selectors.boardCard, (cardElements, wordSelector) => Array.from(cardElements).map(cardElement => { const color = cardElement.getAttribute('aria-label'); const wordElement = cardElement.querySelector(wordSelector); const word = wordElement && wordElement.textContent ? wordElement.textContent : ''; return { word: word.trim(), color }; }), Selectors.boardCardWord ); // Map to BoardWord with enum and revealed const cards: BoardWord[] = rawCards.map(({ word, color }) => ({ word, revealed: false, team: mapColorToTeam(color), })); return { words: cards }; } async submitClue(clue: Clue): Promise { const clueInput = this.page.locator(Selectors.clueInput); const numberSelectorTrigger = this.page.locator(Selectors.numberSelectorTrigger); const giveClueButton = this.page.locator(Selectors.giveClueButton); await clueInput.fill(clue.word); await numberSelectorTrigger.click(); const numberButton = this.page.locator(Selectors.numberOption(clue.count)); await numberButton.waitFor({ timeout: 5000 }); await numberButton.click(); await giveClueButton.click(); } async makeGuess(word: string): Promise { // TODO: Implement clicking the board word } async endTurn(): Promise { const endTurnButton = this.page.locator(Selectors.endTurnButton); await endTurnButton.click(); } // Internal/test-only resource cleanup async _cleanupResources(): Promise { if (this.page && !this.page.isClosed()) { await this.page.close(); } } // Internal: Check if the join spymaster button is visible for a team async _isJoinSpymasterButtonVisible(team: 'red' | 'blue'): Promise { const selector = team === 'red' ? Selectors.joinRedSpymasterButton : Selectors.joinBlueSpymasterButton; const button = this.page.locator(selector); return await button.isVisible().catch(() => false); } // Internal: Check if the clue input is visible async _isClueInputVisible(): Promise { const clueInput = this.page.locator(Selectors.clueInput); return await clueInput.isVisible().catch(() => false); } // Internal: Wait for the clue input to become hidden async _waitForClueInputHidden(timeout = 5000): Promise { const clueInput = this.page.locator(Selectors.clueInput); try { await clueInput.waitFor({ state: 'hidden', timeout }); } catch (e) { await this._handleScreenshot('screenshots/waitForClueInputHidden_timeout.png', '[waitForClueInputHidden] Clue input did not become hidden in time'); throw e; } } // Internal: Utility to take a screenshot and log a message (for timeouts/errors) private async _handleScreenshot(path: string, logMessage?: string): Promise { if (logMessage) console.log(logMessage); await this.page.screenshot({ path }); } }