spymaster-dev-b / browser /handler.ts
Danisz's picture
feat: add multi-agent support to browser, server and UI
6d09523
Raw History Blame Contribute Delete
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 });
}
}