Spaces:
Build error
Build error
Download browser/handler.ts from Danisz/spymaster-dev-b: direct link, hf CLI and curl.
- Browser
- Download file 8.26 kB
-
https://huggingface.co/spaces/Danisz/spymaster-dev-b/resolve/main/browser/handler.ts
- Command line
-
hf download hf://spaces/Danisz/spymaster-dev-b/browser/handler.ts
-
curl -L -o handler.ts https://huggingface.co/spaces/Danisz/spymaster-dev-b/resolve/main/browser/handler.ts
8.26 kB
| 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<CodenamesGameHandler> { | |
| const page = await context.newPage(); | |
| await page.goto(getRoomUrl()); | |
| return new CodenamesGameHandler(page, undefined, context); | |
| } | |
| async joinRoom(): Promise<void> { | |
| // 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<void> { | |
| 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<void> { | |
| 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<BoardState> { | |
| // 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<void> { | |
| 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<void> { | |
| // TODO: Implement clicking the board word | |
| } | |
| async endTurn(): Promise<void> { | |
| const endTurnButton = this.page.locator(Selectors.endTurnButton); | |
| await endTurnButton.click(); | |
| } | |
| // Internal/test-only resource cleanup | |
| async _cleanupResources(): Promise<void> { | |
| 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<boolean> { | |
| 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<boolean> { | |
| 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<void> { | |
| 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<void> { | |
| if (logMessage) console.log(logMessage); | |
| await this.page.screenshot({ path }); | |
| } | |
| } |