import fs from 'node:fs'; import path from 'node:path'; import { chatModels } from '@/lib/ai/models'; import { expect, type Page } from '@playwright/test'; export class ChatPage { constructor(private page: Page) {} public get sendButton() { return this.page.getByTestId('send-button'); } public get stopButton() { return this.page.getByTestId('stop-button'); } public get multimodalInput() { return this.page.getByTestId('multimodal-input'); } public get scrollContainer() { return this.page.locator('.overflow-y-scroll'); } public get scrollToBottomButton() { return this.page.getByTestId('scroll-to-bottom-button'); } async createNewChat() { await this.page.goto('/'); } public getCurrentURL(): string { return this.page.url(); } async sendUserMessage(message: string) { await this.multimodalInput.click(); await this.multimodalInput.fill(message); await this.sendButton.click(); } async isGenerationComplete() { const response = await this.page.waitForResponse((response) => response.url().includes('/api/chat'), ); await response.finished(); } async isVoteComplete() { const response = await this.page.waitForResponse((response) => response.url().includes('/api/vote'), ); await response.finished(); } async hasChatIdInUrl() { await expect(this.page).toHaveURL( /^http:\/\/localhost:3000\/chat\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, ); } async sendUserMessageFromSuggestion() { await this.page .getByRole('button', { name: 'What are the advantages of' }) .click(); } async isElementVisible(elementId: string) { await expect(this.page.getByTestId(elementId)).toBeVisible(); } async isElementNotVisible(elementId: string) { await expect(this.page.getByTestId(elementId)).not.toBeVisible(); } async addImageAttachment() { this.page.on('filechooser', async (fileChooser) => { const filePath = path.join( process.cwd(), 'public', 'images', 'mouth of the seine, monet.jpg', ); const imageBuffer = fs.readFileSync(filePath); await fileChooser.setFiles({ name: 'mouth of the seine, monet.jpg', mimeType: 'image/jpeg', buffer: imageBuffer, }); }); await this.page.getByTestId('attachments-button').click(); } public async getSelectedModel() { const modelId = await this.page.getByTestId('model-selector').innerText(); return modelId; } public async chooseModelFromSelector(chatModelId: string) { const chatModel = chatModels.find( (chatModel) => chatModel.id === chatModelId, ); if (!chatModel) { throw new Error(`Model with id ${chatModelId} not found`); } await this.page.getByTestId('model-selector').click(); await this.page.getByTestId(`model-selector-item-${chatModelId}`).click(); expect(await this.getSelectedModel()).toBe(chatModel.name); } public async getSelectedVisibility() { const visibilityId = await this.page .getByTestId('visibility-selector') .innerText(); return visibilityId; } public async chooseVisibilityFromSelector( chatVisibility: 'public' | 'private', ) { await this.page.getByTestId('visibility-selector').click(); await this.page .getByTestId(`visibility-selector-item-${chatVisibility}`) .click(); expect(await this.getSelectedVisibility()).toBe(chatVisibility); } async getRecentAssistantMessage() { const messageElements = await this.page .getByTestId('message-assistant') .all(); const lastMessageElement = messageElements[messageElements.length - 1]; const content = await lastMessageElement .getByTestId('message-content') .innerText() .catch(() => null); const reasoningElement = await lastMessageElement .getByTestId('message-reasoning') .isVisible() .then(async (visible) => visible ? await lastMessageElement .getByTestId('message-reasoning') .innerText() : null, ) .catch(() => null); return { element: lastMessageElement, content, reasoning: reasoningElement, async toggleReasoningVisibility() { await lastMessageElement .getByTestId('message-reasoning-toggle') .click(); }, async upvote() { await lastMessageElement.getByTestId('message-upvote').click(); }, async downvote() { await lastMessageElement.getByTestId('message-downvote').click(); }, }; } async getRecentUserMessage() { const messageElements = await this.page.getByTestId('message-user').all(); const lastMessageElement = messageElements.at(-1); if (!lastMessageElement) { throw new Error('No user message found'); } const content = await lastMessageElement .getByTestId('message-content') .innerText() .catch(() => null); const hasAttachments = await lastMessageElement .getByTestId('message-attachments') .isVisible() .catch(() => false); const attachments = hasAttachments ? await lastMessageElement.getByTestId('message-attachments').all() : []; const page = this.page; return { element: lastMessageElement, content, attachments, async edit(newMessage: string) { await page.getByTestId('message-edit-button').click(); await page.getByTestId('message-editor').fill(newMessage); await page.getByTestId('message-editor-send-button').click(); await expect( page.getByTestId('message-editor-send-button'), ).not.toBeVisible(); }, }; } async expectToastToContain(text: string) { await expect(this.page.getByTestId('toast')).toContainText(text); } async openSideBar() { const sidebarToggleButton = this.page.getByTestId('sidebar-toggle-button'); await sidebarToggleButton.click(); } public async isScrolledToBottom(): Promise { return this.scrollContainer.evaluate( (el) => Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 1, ); } public async waitForScrollToBottom(timeout = 5_000): Promise { const start = Date.now(); while (Date.now() - start < timeout) { if (await this.isScrolledToBottom()) return; await this.page.waitForTimeout(100); } throw new Error(`Timed out waiting for scroll bottom after ${timeout}ms`); } public async sendMultipleMessages( count: number, makeMessage: (i: number) => string, ) { for (let i = 0; i < count; i++) { await this.sendUserMessage(makeMessage(i)); await this.isGenerationComplete(); } } public async scrollToTop(): Promise { await this.scrollContainer.evaluate((element) => { element.scrollTop = 0; }); } }