import { Page, expect } from '@playwright/test'; import { BasePage } from './BasePage'; import { ChatMessage, BargainingContext, Ware, SuggestedAction } from '../types'; /** * Chat Panel Page Object * Handles NPC chat interactions embedded in dashboard and other pages */ export class ChatPanel extends BasePage { // Overall character panel container private readonly characterPanel = () => this.page.locator('[data-testid="character-panel"]').first(); // Chat messages container (scoped inside character panel) private readonly chatPanel = () => this.characterPanel().locator('[data-testid="chat-panel"]').first(); private readonly chatMessages = () => this.chatPanel().locator('[data-testid="npc-message"], [data-testid="user-message"]'); private readonly userMessages = () => this.chatPanel().locator('[data-testid="user-message"]'); private readonly npcMessages = () => this.chatPanel().locator('[data-testid="npc-message"]'); // Input and actions (scoped to outer panel, not messages container) private readonly messageInput = () => this.characterPanel().locator('[data-testid="chat-input"]').first(); private readonly sendButton = () => this.characterPanel().getByRole('button', { name: /^send$/i }).first(); private readonly resetButton = () => this.characterPanel().getByRole('button', { name: /new opener/i }).first(); // Character selection buttons (scoped to outer panel) private readonly characterSelector = () => this.characterPanel().locator('button').filter({ hasText: /frodo|sam|gandalf/i }); private readonly characterTabs = () => this.characterPanel().locator('button').filter({ hasText: /frodo|sam|gandalf/i }); // Suggested actions (scoped to outer panel) private readonly suggestedAction = () => this.characterPanel().locator('div').filter({ hasText: /^Suggested action/ }).first(); private readonly suggestedActionButton = () => this.characterPanel().locator('[data-testid="suggested-action"] button').first(); constructor(page: Page, baseUrl?: string) { super(page, baseUrl); } /** * Get all chat messages as plain text array */ async getMessages(): Promise { const allMessages = this.chatMessages(); const messages: string[] = []; const count = await allMessages.count(); for (let i = 0; i < count; i++) { const msg = allMessages.nth(i); const text = await msg.textContent(); if (text) { messages.push(text.trim()); } } return messages; } /** * Get available actions (suggested action buttons) */ async getAvailableActions(): Promise { const actions: string[] = []; const suggestedAction = this.suggestedAction(); const isVisible = await suggestedAction.isVisible({ timeout: 2000 }).catch(() => false); if (isVisible) { const text = await suggestedAction.textContent(); if (text) { actions.push(text.trim()); } } return actions; } /** * Check if chat panel is loaded and has initial message */ async isLoaded(): Promise { try { const charPanel = this.characterPanel(); const isVisible = await charPanel.isVisible({ timeout: 3000 }); if (!isVisible) return false; const messages = await this.getMessages(); return messages.length > 0; } catch { return false; } } /** * Open chat panel if it has an open method */ async open(): Promise { const charPanel = this.characterPanel(); try { await charPanel.click(); await this.page.waitForTimeout(300); } catch { // Panel might already be open } } /** * Check if chat panel is visible */ async isVisible(): Promise { return this.characterPanel().isVisible({ timeout: 3000 }).catch(() => false); } /** * Send a message in chat */ async sendMessage(message: string): Promise { // Wait for the send button to be enabled (not loading) before interacting await this.sendButton().waitFor({ state: 'visible', timeout: 15000 }); await this.sendButton().waitFor({ state: 'attached', timeout: 15000 }); // Poll until button is not disabled (isChatLoading=false) await this.page.waitForFunction( () => { const btn = document.querySelector('[data-testid="send-button"]') as HTMLButtonElement | null; return btn !== null && !btn.disabled; }, { timeout: 15000 } ).catch(() => {}); const initialCount = await this.npcMessages().count(); await this.messageInput().fill(message); await this.sendButton().click(); // Wait for NPC to respond with a new message await this.page.waitForFunction( ([selector, minCount]) => { const els = document.querySelectorAll(selector as string); return els.length > (minCount as number); }, ['[data-testid="npc-message"]', initialCount] as [string, number], { timeout: 25000 } ).catch(() => { // Timeout waiting for response - continue anyway }); } /** * Get last NPC message * Polls for message to be rendered */ async getLastNPCMessage(): Promise { const messages = this.npcMessages(); try { const count = await messages.count(); if (count === 0) { // Poll for first message to appear await this.pollUntil( async () => { const c = await this.npcMessages().count(); return c > 0; }, 10000, 500, ); } } catch { // Continue anyway } const finalCount = await messages.count(); if (finalCount === 0) return ''; const lastMessage = messages.nth(finalCount - 1); return lastMessage.textContent().then(t => t || ''); } /** * Get last user message */ async getLastUserMessage(): Promise { const messages = this.userMessages(); const count = await messages.count(); if (count === 0) return ''; const lastMessage = messages.nth(count - 1); return lastMessage.textContent().then(t => t || ''); } /** * Get all chat messages */ async getAllMessages(): Promise> { const messages: Array<{ role: string; text: string }> = []; const allMessages = this.chatMessages(); const count = await allMessages.count(); for (let i = 0; i < count; i++) { const msg = allMessages.nth(i); const text = await msg.textContent(); // Read the data-testid attribute to determine role const testId = await msg.getAttribute('data-testid'); const role = testId === 'user-message' ? 'user' : 'npc'; messages.push({ role, text: text || '', }); } return messages; } /** * Check if suggested action is visible */ async isSuggestedActionVisible(): Promise { return this.suggestedAction().isVisible({ timeout: 3000 }).catch(() => false); } /** * Get suggested action text */ async getSuggestedActionText(): Promise { return this.suggestedAction().textContent().then(t => t || ''); } /** * Click suggested action button */ async clickSuggestedAction(): Promise { await this.suggestedActionButton().click(); } /** * Select a character to chat with * Let the UI handle conversation state - use resetNpcChat if fresh conversation needed */ async selectCharacter(characterName: 'frodo' | 'sam' | 'gandalf'): Promise { const tab = this.characterTabs().filter({ hasText: new RegExp(characterName, 'i') }).first(); await tab.click(); // Wait for at least one NPC message to appear (opening message / catalog) await this.page.waitForFunction( () => document.querySelectorAll('[data-testid="npc-message"]').length > 0, { timeout: 15000 } ).catch(() => {}); // Wait for send button to become enabled (isChatLoading=false) await this.page.waitForFunction( () => { const btn = document.querySelector('[data-testid="send-button"]') as HTMLButtonElement | null; return btn !== null && !btn.disabled; }, { timeout: 10000 } ).catch(() => {}); } /** * Reset conversation */ async resetConversation(): Promise { await this.resetButton().click(); } /** * Verify message contains text */ async expectLastNPCMessageContains(text: string | RegExp): Promise { const message = await this.getLastNPCMessage(); if (typeof text === 'string') { expect(message).toContain(text); } else { expect(message).toMatch(text); } } /** * Wait for NPC to respond * Uses active polling for response message visibility */ async waitForNPCResponse(timeout: number = 15000): Promise { try { await this.pollForElementVisible( this.npcMessages().last(), timeout, 500, ); } catch { // Message may not appear, but that's ok } } }