import { type Page } from '@playwright/test'; import { test, expect, type Client } from '../../fixtures/multi-client'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page'; const MOCK_EMBED_URL = 'https://example.test/mock-embed'; const MOCK_EMBED_TITLE = 'Mock Embed Title'; const MOCK_EMBED_DESCRIPTION = 'Mock embed description for chat E2E coverage.'; const MOCK_GIF_IMAGE_URL = 'data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='; const DELETED_MESSAGE_CONTENT = '[Message deleted]'; test.describe('Chat messaging features', () => { test.describe.configure({ timeout: 180_000 }); test('syncs messages in a newly created text channel', async ({ createClient }) => { const scenario = await createChatScenario(createClient); const channelName = uniqueName('updates'); const aliceMessage = `Alice text channel message ${uniqueName('msg')}`; const bobMessage = `Bob text channel reply ${uniqueName('msg')}`; await test.step('Alice creates a new text channel and both users join it', async () => { await scenario.aliceRoom.ensureTextChannelExists(channelName); await scenario.aliceRoom.joinTextChannel(channelName); await scenario.bobRoom.joinTextChannel(channelName); }); await test.step('Alice and Bob see synced messages in the new text channel', async () => { await scenario.aliceMessages.sendMessage(aliceMessage); await expect(scenario.bobMessages.getMessageItemByText(aliceMessage)).toBeVisible({ timeout: 20_000 }); await scenario.bobMessages.sendMessage(bobMessage); await expect(scenario.aliceMessages.getMessageItemByText(bobMessage)).toBeVisible({ timeout: 20_000 }); }); }); test('shows typing indicators to other users', async ({ createClient }) => { const scenario = await createChatScenario(createClient); const draftMessage = `Typing indicator draft ${uniqueName('draft')}`; await test.step('Alice starts typing in general channel', async () => { await scenario.aliceMessages.typeDraft(draftMessage); }); await test.step('Bob sees Alice typing', async () => { await expect(scenario.bob.page.getByText('Alice is typing...')).toBeVisible({ timeout: 10_000 }); }); }); test('edits and removes messages for both users', async ({ createClient }) => { const scenario = await createChatScenario(createClient); const originalMessage = `Editable message ${uniqueName('edit')}`; const updatedMessage = `Edited message ${uniqueName('edit')}`; await test.step('Alice sends a message and Bob receives it', async () => { await scenario.aliceMessages.sendMessage(originalMessage); await expect(scenario.bobMessages.getMessageItemByText(originalMessage)).toBeVisible({ timeout: 20_000 }); }); await test.step('Alice edits the message and both users see updated content', async () => { await scenario.aliceMessages.editOwnMessage(originalMessage, updatedMessage); await expect(scenario.aliceMessages.getMessageItemByText(updatedMessage)).toBeVisible({ timeout: 20_000 }); await expect(scenario.alice.page.getByText('(edited)')).toBeVisible({ timeout: 10_000 }); await expect(scenario.bobMessages.getMessageItemByText(updatedMessage)).toBeVisible({ timeout: 20_000 }); }); await test.step('Alice deletes the message and both users see deletion state', async () => { await scenario.aliceMessages.deleteOwnMessage(updatedMessage); await expect(scenario.aliceMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 20_000 }); await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 20_000 }); }); }); test('syncs image and file attachments between users', async ({ createClient }) => { const scenario = await createChatScenario(createClient); const imageName = `${uniqueName('diagram')}.svg`; const fileName = `${uniqueName('notes')}.txt`; const imageCaption = `Image upload ${uniqueName('caption')}`; const fileCaption = `File upload ${uniqueName('caption')}`; const imageAttachment = createTextFilePayload(imageName, 'image/svg+xml', buildMockSvgMarkup(imageName)); const fileAttachment = createTextFilePayload(fileName, 'text/plain', `Attachment body for ${fileName}`); await test.step('Alice sends image attachment and Bob receives it', async () => { await scenario.aliceMessages.attachFiles([imageAttachment]); await scenario.aliceMessages.sendMessage(imageCaption); await scenario.aliceMessages.expectMessageImageLoaded(imageName); await expect(scenario.bobMessages.getMessageItemByText(imageCaption)).toBeVisible({ timeout: 20_000 }); await scenario.bobMessages.expectMessageImageLoaded(imageName); }); await test.step('Alice sends generic file attachment and Bob receives it', async () => { await scenario.aliceMessages.attachFiles([fileAttachment]); await scenario.aliceMessages.sendMessage(fileCaption); await expect(scenario.bobMessages.getMessageItemByText(fileCaption)).toBeVisible({ timeout: 20_000 }); await expect(scenario.bob.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 }); }); }); test('renders link embeds for shared links', async ({ createClient }) => { const scenario = await createChatScenario(createClient); const messageText = `Useful docs ${MOCK_EMBED_URL}`; await test.step('Alice shares a link in chat', async () => { await scenario.aliceMessages.sendMessage(messageText); await expect(scenario.bobMessages.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 }); }); await test.step('Both users see mocked link embed metadata', async () => { await expect(scenario.aliceMessages.getEmbedCardByTitle(MOCK_EMBED_TITLE)).toBeVisible({ timeout: 20_000 }); await expect(scenario.bobMessages.getEmbedCardByTitle(MOCK_EMBED_TITLE)).toBeVisible({ timeout: 20_000 }); await expect(scenario.bob.page.getByText(MOCK_EMBED_DESCRIPTION)).toBeVisible({ timeout: 20_000 }); }); }); test('sends KLIPY GIF messages with mocked API responses', async ({ createClient }) => { const scenario = await createChatScenario(createClient); await test.step('Alice opens GIF picker and sends mocked GIF', async () => { await scenario.aliceMessages.openGifPicker(); await scenario.aliceMessages.selectFirstGif(); }); await test.step('Bob sees GIF message sync', async () => { await scenario.aliceMessages.expectMessageImageLoaded('KLIPY GIF'); await scenario.bobMessages.expectMessageImageLoaded('KLIPY GIF'); }); }); }); type ChatScenario = { alice: Client; bob: Client; aliceRoom: ChatRoomPage; bobRoom: ChatRoomPage; aliceMessages: ChatMessagesPage; bobMessages: ChatMessagesPage; }; async function createChatScenario(createClient: () => Promise): Promise { const suffix = uniqueName('chat'); const serverName = `Chat Server ${suffix}`; const aliceCredentials = { username: `alice_${suffix}`, displayName: 'Alice', password: 'TestPass123!' }; const bobCredentials = { username: `bob_${suffix}`, displayName: 'Bob', password: 'TestPass123!' }; const alice = await createClient(); const bob = await createClient(); await installChatFeatureMocks(alice.page); await installChatFeatureMocks(bob.page); const aliceRegisterPage = new RegisterPage(alice.page); const bobRegisterPage = new RegisterPage(bob.page); await aliceRegisterPage.goto(); await aliceRegisterPage.register( aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password ); await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 }); await bobRegisterPage.goto(); await bobRegisterPage.register( bobCredentials.username, bobCredentials.displayName, bobCredentials.password ); await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 }); const aliceSearchPage = new ServerSearchPage(alice.page); await aliceSearchPage.createServer(serverName, { description: 'E2E chat server for messaging feature coverage' }); await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); const bobSearchPage = new ServerSearchPage(bob.page); const serverCard = bob.page.locator('button', { hasText: serverName }).first(); await bobSearchPage.searchInput.fill(serverName); await expect(serverCard).toBeVisible({ timeout: 15_000 }); await serverCard.click(); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); const aliceRoom = new ChatRoomPage(alice.page); const bobRoom = new ChatRoomPage(bob.page); const aliceMessages = new ChatMessagesPage(alice.page); const bobMessages = new ChatMessagesPage(bob.page); await aliceMessages.waitForReady(); await bobMessages.waitForReady(); return { alice, bob, aliceRoom, bobRoom, aliceMessages, bobMessages }; } async function installChatFeatureMocks(page: Page): Promise { await page.route('**/api/klipy/config', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true }) }); }); await page.route('**/api/klipy/gifs**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, hasNext: false, results: [ { id: 'mock-gif-1', slug: 'mock-gif-1', title: 'Mock Celebration GIF', url: MOCK_GIF_IMAGE_URL, previewUrl: MOCK_GIF_IMAGE_URL, width: 64, height: 64 } ] }) }); }); await page.route('**/api/link-metadata**', async (route) => { const requestUrl = new URL(route.request().url()); const requestedTargetUrl = requestUrl.searchParams.get('url') ?? ''; if (requestedTargetUrl === MOCK_EMBED_URL) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ title: MOCK_EMBED_TITLE, description: MOCK_EMBED_DESCRIPTION, imageUrl: MOCK_GIF_IMAGE_URL, siteName: 'Mock Docs' }) }); return; } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ failed: true }) }); }); } function createTextFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload { return { name, mimeType, base64: Buffer.from(content, 'utf8').toString('base64') }; } function buildMockSvgMarkup(label: string): string { return [ '', '', '', '', '', `${label}`, '' ].join(''); } function uniqueName(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; }