281 lines
8.8 KiB
TypeScript
281 lines
8.8 KiB
TypeScript
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<string[]> {
|
|
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<string[]> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<boolean> {
|
|
return this.characterPanel().isVisible({ timeout: 3000 }).catch(() => false);
|
|
}
|
|
|
|
/**
|
|
* Send a message in chat
|
|
*/
|
|
async sendMessage(message: string): Promise<void> {
|
|
// 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<string> {
|
|
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<string> {
|
|
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<Array<{ role: string; text: string }>> {
|
|
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<boolean> {
|
|
return this.suggestedAction().isVisible({ timeout: 3000 }).catch(() => false);
|
|
}
|
|
|
|
/**
|
|
* Get suggested action text
|
|
*/
|
|
async getSuggestedActionText(): Promise<string> {
|
|
return this.suggestedAction().textContent().then(t => t || '');
|
|
}
|
|
|
|
/**
|
|
* Click suggested action button
|
|
*/
|
|
async clickSuggestedAction(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await this.resetButton().click();
|
|
}
|
|
|
|
/**
|
|
* Verify message contains text
|
|
*/
|
|
async expectLastNPCMessageContains(text: string | RegExp): Promise<void> {
|
|
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<void> {
|
|
try {
|
|
await this.pollForElementVisible(
|
|
this.npcMessages().last(),
|
|
timeout,
|
|
500,
|
|
);
|
|
} catch {
|
|
// Message may not appear, but that's ok
|
|
}
|
|
}
|
|
}
|