Spaces:
Build error
Build error
File size: 8,261 Bytes
6d09523 96dfcfb 19c41e0 96dfcfb 19c41e0 6d09523 19c41e0 6d09523 19c41e0 6d09523 19c41e0 6d09523 19c41e0 6d09523 19c41e0 6d09523 19c41e0 1f0b59d 19c41e0 96dfcfb 1f0b59d 96dfcfb 1f0b59d 19c41e0 96dfcfb 19c41e0 6d09523 19c41e0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | 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 });
}
} |