Some checks failed
Deploy Web Apps / deploy (push) Has been cancelled
Queue Release Build / prepare (push) Successful in 21s
Queue Release Build / build-linux (push) Successful in 27m44s
Queue Release Build / build-windows (push) Successful in 32m16s
Queue Release Build / finalize (push) Successful in 1m54s
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
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<Client>): Promise<ChatScenario> {
|
|
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<void> {
|
|
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 [
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
|
|
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
|
|
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
|
|
'<rect x="66" y="28" width="64" height="16" rx="8" fill="#f8fafc" />',
|
|
'<rect x="24" y="74" width="112" height="12" rx="6" fill="#22c55e" />',
|
|
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${label}</text>`,
|
|
'</svg>'
|
|
].join('');
|
|
}
|
|
|
|
function uniqueName(prefix: string): string {
|
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|