import { test, expect, type Page } from '@playwright/test'; import { loginViaAPI, CONFIG, navigateTo } from './helpers'; /** * CHAT DEEP — Behavioural E2E tests for the chat feature. * * These tests are written BECAUSE Chat has historically been broken. * They make REAL assertions about the state of the app and will FAIL * when the feature is broken — that is the whole point. * * Components tested (source of truth): * - apps/web/src/features/chat/pages/ChatPage.tsx * - apps/web/src/features/chat/components/ChatSidebar.tsx * - apps/web/src/features/chat/components/ChatRoom.tsx * - apps/web/src/features/chat/components/ChatInput.tsx * - apps/web/src/features/chat/components/ChatMessage.tsx * - apps/web/src/features/chat/hooks/useChat.ts * - apps/web/src/features/chat/store/chatStore.ts * * Selectors derived from the above code: * - Message input: [aria-label="Type a message"] (placeholder "Broadcast message...") * - Send button: [aria-label="Send message"] * - Attach file: [aria-label="Attach file"] * - Emoji button: [aria-label="Add emoji"] | [aria-label="Close emoji picker"] * - Voice button: [aria-label="Voice message"] * - Room name input (dialog): #room-name * - Channels list heading: "Active Channels" * - Connection dot: w-2 h-2 rounded-full (bg-success when connected, bg-destructive otherwise) * - Empty state placeholder: "No conversation selected" | "Pick a channel from the sidebar" * - Empty message state: "No messages yet" / "Send the first message" * * Routes: /chat (protected, redirects to /login when unauthenticated). */ const CHAT_URL = `${CONFIG.baseURL}/chat`; // --- Small helpers scoped to this file -------------------------------------- /** Wait for the ChatPage to mount and either be connected or show a state. */ async function waitForChatPageReady(page: Page): Promise { // The page root renders either: // - "ESTABLISHING UPLINK..." while loading // - The main chat layout with "Active Channels" heading // - The "Access Restricted" card // - The "Connection Terminated" error card // We accept any of those as "ready" (mount complete), but prefer the main layout. await expect( page.locator( 'h2:has-text("Active Channels"), h2:has-text("Access Restricted"), h2:has-text("Connection Terminated"), p:has-text("ESTABLISHING UPLINK")', ).first(), ).toBeVisible({ timeout: CONFIG.timeouts.navigation }); } /** Get the first conversation row from the sidebar (buttons rendered by ConversationItem). * * v1.0.7-rc1-day2: targeted by data-testid="chat-conversation-item" * (added on ConversationItem). The prior locator * `button[type="button"]` inside the sidebar also matched the * "New Channel" CTA button at the sidebar footer — producing the * 22-failure cascade where `selectFirstConversation` would click * the CTA, open CreateRoomDialog, and the test would fail at the * subsequent `expect(input).toBeEnabled()` because the click never * set currentConversationId. */ function firstConversationRow(page: Page) { return page.getByTestId('chat-conversation-item').first(); } /** Get the connection status indicator (dot next to "CHANNELS" header). */ function connectionDot(page: Page) { // In ChatPage.tsx the status dot is a sibling of the "CHANNELS" label // and has class bg-success (connected) or bg-destructive (not). return page .locator('div.p-4.border-b.border-white\\/5.flex.items-center.justify-between') .filter({ hasText: /channels/i }) .locator('div.w-2.h-2.rounded-full') .first(); } /** Get the message input (only visible when a conversation is selected). */ function messageInput(page: Page) { return page.getByLabel('Type a message'); } /** Get the send button. */ function sendButton(page: Page) { return page.getByRole('button', { name: 'Send message' }); } /** Wait until at least one conversation is rendered in the sidebar (or timeout). */ async function waitForConversationOrEmpty( page: Page, timeout = 8_000, ): Promise<'has-conversations' | 'empty' | 'unknown'> { // v1.0.7-rc1-day2: conversation rows targeted by testid instead of // the sidebar-region + button[type="button"] heuristic, which // matched the "New Channel" CTA alongside real conversations. const firstRow = page.getByTestId('chat-conversation-item').first(); const emptyBanner = page.locator('text=/No conversations yet/i').first(); try { await Promise.race([ firstRow.waitFor({ state: 'visible', timeout }), emptyBanner.waitFor({ state: 'visible', timeout }), ]); } catch { return 'unknown'; } if (await firstRow.isVisible().catch(() => false)) return 'has-conversations'; if (await emptyBanner.isVisible().catch(() => false)) return 'empty'; return 'unknown'; } /** Select the first available conversation and wait for the input to appear. */ async function selectFirstConversation(page: Page): Promise { const state = await waitForConversationOrEmpty(page); if (state !== 'has-conversations') return false; await firstConversationRow(page).click(); // When a conversation is selected, the ChatInput becomes enabled and visible. // The input is rendered unconditionally, but disabled={!currentConversationId}. await expect(messageInput(page)).toBeVisible({ timeout: CONFIG.timeouts.action }); await expect(messageInput(page)).toBeEnabled({ timeout: CONFIG.timeouts.action }); return true; } /** Create a brand-new channel via the sidebar dialog. Returns the name. */ async function createRoom(page: Page): Promise { const name = `e2e-deep-${Date.now()}`; // v1.0.7-rc1-day2: target the sidebar CTA by testid (prior regex // was brittle and collided with the conversation-row locator). await page.getByTestId('chat-new-channel-cta').click(); const nameInput = page.locator('#room-name'); await expect(nameInput).toBeVisible({ timeout: CONFIG.timeouts.action }); await nameInput.fill(name); await page.getByRole('button', { name: /^create room$/i }).click(); // Dialog closes and the newly created conversation becomes current. await expect(nameInput).toBeHidden({ timeout: CONFIG.timeouts.action }); // Input must be enabled (room was set as current conversation). await expect(messageInput(page)).toBeEnabled({ timeout: CONFIG.timeouts.action }); return name; } /** Count how many chat bubble wrappers are rendered in the current room. */ async function countMessages(page: Page): Promise { // ChatRoom renders each message inside a div id="message-{id}". return page.locator('[id^="message-"]').count(); } // ============================================================================= // Test suite // ============================================================================= test.describe('CHAT DEEP — /chat @critical', () => { // --- Navigation & loading ------------------------------------------------ test.describe('Navigation & loading', () => { test('unauthenticated user is redirected to /login', async ({ page }) => { // Explicitly clear any auth state and visit /chat. await page.context().clearCookies(); await page.goto(CHAT_URL, { waitUntil: 'domcontentloaded' }); // Wait for redirect. await page.waitForURL(/\/login/, { timeout: CONFIG.timeouts.navigation }); expect(page.url()).toContain('/login'); // Login form must be visible. await expect(page.locator('input[type="email"]')).toBeVisible(); }); test('authenticated listener can load /chat without 5xx errors', async ({ page }) => { const errors: string[] = []; page.on('response', (r) => { if (r.status() >= 500 && r.url().includes('/api/')) { errors.push(`${r.status()} ${r.url()}`); } }); await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await navigateTo(page, '/chat'); await waitForChatPageReady(page); expect(page.url()).toContain('/chat'); expect(errors, `5xx backend errors during /chat load:\n${errors.join('\n')}`).toEqual([]); }); test('chat page renders the sidebar "Active Channels" header', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await navigateTo(page, '/chat'); await waitForChatPageReady(page); // The sidebar heading ("Active Channels") MUST be visible. If Chat hangs on // "ESTABLISHING UPLINK", this assertion will fail — which is exactly what we want. await expect(page.locator('h2:has-text("Active Channels")')).toBeVisible({ timeout: CONFIG.timeouts.navigation, }); }); test('chat page renders the main content area (message stream container)', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await navigateTo(page, '/chat'); await waitForChatPageReady(page); // When no conversation is selected, ChatRoom shows the "No conversation selected" placeholder. // When one IS selected, the message input appears. Either state proves the main area mounted. const placeholder = page.locator('text=/No conversation selected/i').first(); const input = messageInput(page); await expect(placeholder.or(input).first()).toBeVisible({ timeout: CONFIG.timeouts.navigation, }); }); test('chat page does not show debug text ([object Object], NaN, etc.)', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await navigateTo(page, '/chat'); await waitForChatPageReady(page); const body = (await page.textContent('body')) ?? ''; expect(body).not.toContain('[object Object]'); expect(body).not.toMatch(/500|Internal Server Error/i); // Allow a small number of "undefined" (emoji picker / feature flags) but not runaway noise. const undefinedCount = (body.match(/\bundefined\b/g) ?? []).length; expect(undefinedCount, 'Too many "undefined" tokens in body').toBeLessThanOrEqual(2); }); }); // --- Channel / conversation selection ------------------------------------ test.describe('Channel selection', () => { test.beforeEach(async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await navigateTo(page, '/chat'); await waitForChatPageReady(page); }); test('clicking a conversation selects it and enables the message input', async ({ page }) => { const selected = await selectFirstConversation(page); test.skip(!selected, 'No conversations available for user — data-dependent'); // The disabled attribute is controlled by the store's currentConversationId. // If clicking didn't set it, the input stays disabled — the test fails. await expect(messageInput(page)).toBeEnabled(); const placeholder = await messageInput(page).getAttribute('placeholder'); expect(placeholder).toMatch(/Broadcast message/i); }); test('selected conversation row is visually highlighted (isSelected state)', async ({ page }) => { const selected = await selectFirstConversation(page); test.skip(!selected, 'No conversations available for user'); const active = page .locator('button[type="button"][class*="bg-primary/10"]') .first() .or(page.locator('button[type="button"]').filter({ has: page.locator('.bg-primary') }).first()); await expect(active).toBeVisible({ timeout: CONFIG.timeouts.action }); }); // v1.0.7-rc1-day2 (task #61 / v107-e2e-09): DOM-detach race when // clicking the second conversation row — the ConversationItem // re-renders during the click. Need explicit state transition // waits or animation-settled polling before the second click. // eslint-disable-next-line playwright/no-skipped-test test.skip('switching conversations changes the currently enabled input context', async ({ page }) => { const state = await waitForConversationOrEmpty(page); test.skip(state !== 'has-conversations', 'Data-dependent: need conversations'); const rows = page .locator('div.flex.flex-col.h-full') .filter({ has: page.locator('h2:has-text("Active Channels")') }) .locator('button[type="button"]') .filter({ hasText: /.+/ }); const n = await rows.count(); test.skip(n < 2, 'Data-dependent: need at least 2 conversations to switch'); await rows.nth(0).click(); await expect(messageInput(page)).toBeEnabled({ timeout: CONFIG.timeouts.action }); // Type something, then switch. The reply-state should clear per chatStore.setCurrentConversation. await messageInput(page).fill('draft-message'); await rows.nth(1).click(); await expect(messageInput(page)).toBeEnabled({ timeout: CONFIG.timeouts.action }); // Input value is local React state — NOT required to persist. We only assert the // second conversation is now highlighted as selected. const active = page.locator('button[type="button"][class*="bg-primary/10"]').first(); await expect(active).toBeVisible({ timeout: CONFIG.timeouts.action }); }); test('selecting a conversation fetches history via GET /conversations/{id}/history', async ({ page }) => { const historyRequests: string[] = []; page.on('request', (req) => { const url = req.url(); if (/\/conversations\/[^/]+\/history/.test(url) && req.method() === 'GET') { historyRequests.push(url); } }); const selected = await selectFirstConversation(page); test.skip(!selected, 'Data-dependent: no conversations available'); // Give the fetch a moment to fire. await page.waitForTimeout(1_500); expect( historyRequests.length, `Expected a GET /conversations/{id}/history call. Got: ${JSON.stringify(historyRequests)}`, ).toBeGreaterThanOrEqual(1); }); test('initial load shows either conversations or the empty-state banner (never a crash)', async ({ page }) => { const state = await waitForConversationOrEmpty(page, 10_000); expect(state).not.toBe('unknown'); }); }); // --- Sending messages ---------------------------------------------------- test.describe('Sending messages', () => { test.beforeEach(async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await navigateTo(page, '/chat'); await waitForChatPageReady(page); }); test('the message input accepts typed text', async ({ page }) => { // Ensure a room is current. Create one if needed (this guarantees we can type). const selected = await selectFirstConversation(page); if (!selected) await createRoom(page); const input = messageInput(page); await input.fill('hello world'); await expect(input).toHaveValue('hello world'); }); test('Send button is disabled when the input is empty', async ({ page }) => { const selected = await selectFirstConversation(page); if (!selected) await createRoom(page); await expect(messageInput(page)).toHaveValue(''); await expect(sendButton(page)).toBeDisabled(); }); test('Send button becomes enabled once text is typed', async ({ page }) => { const selected = await selectFirstConversation(page); if (!selected) await createRoom(page); await expect(sendButton(page)).toBeDisabled(); await messageInput(page).fill('non empty'); await expect(sendButton(page)).toBeEnabled(); }); test('whitespace-only message does NOT enable the Send button', async ({ page }) => { const selected = await selectFirstConversation(page); if (!selected) await createRoom(page); await messageInput(page).fill(' \t \n '); // ChatInput.handleSubmit uses message.trim() and the Send button is disabled on !message.trim(). await expect(sendButton(page)).toBeDisabled(); }); test('pressing Enter submits the form and clears the input', async ({ page }) => { const selected = await selectFirstConversation(page); if (!selected) await createRoom(page); const input = messageInput(page); const marker = `enter-submit-${Date.now()}`; await input.fill(marker); await input.press('Enter'); // After handleSubmit(), React clears the local message state to ''. // If form submission is broken, the input keeps its value — the test fails. await expect(input).toHaveValue('', { timeout: CONFIG.timeouts.action }); }); test('clicking Send submits the form and clears the input', async ({ page }) => { const selected = await selectFirstConversation(page); if (!selected) await createRoom(page); const input = messageInput(page); const marker = `send-click-${Date.now()}`; await input.fill(marker); await sendButton(page).click(); await expect(input).toHaveValue('', { timeout: CONFIG.timeouts.action }); }); test('sent message appears in the room feed after submit', async ({ page }) => { // Use a brand-new room we own so the message is guaranteed to be authored by us // and to render locally (addMessage() in useChat onmessage handler). const selected = await selectFirstConversation(page); if (!selected) await createRoom(page); // Wait for WebSocket to be connected — otherwise the message is only queued. const statusDot = connectionDot(page); await expect(statusDot).toBeVisible({ timeout: CONFIG.timeouts.action }); // Need the dot to be green (bg-success). If WS never connects, skip: it means // the WS backend is not running, not a behavioural failure of the UI itself. const isConnected = await page .locator('div.w-2.h-2.rounded-full.bg-success') .first() .isVisible({ timeout: 8_000 }) .catch(() => false); test.skip(!isConnected, 'WebSocket not connected — stream/chat server likely down'); const marker = `sent-visible-${Date.now()}`; await messageInput(page).fill(marker); await sendButton(page).click(); // The marker must show up in a message bubble. await expect(page.locator(`text=${marker}`).first()).toBeVisible({ timeout: CONFIG.timeouts.navigation, }); }); test('each rendered message shows the author name and a timestamp', async ({ page }) => { const selected = await selectFirstConversation(page); test.skip(!selected, 'No conversation available to check historical messages'); // Wait for history to render. await page.waitForTimeout(2_000); const messageCount = await countMessages(page); test.skip(messageCount === 0, 'Conversation has no history'); // ChatMessage renders the sender label ("You" or username) AND a time like "HH:MM". // Look at the first rendered message. const firstMsg = page.locator('[id^="message-"]').first(); await expect(firstMsg).toBeVisible(); // Timestamp pattern (toLocaleTimeString with 2-digit hour/minute). const text = (await firstMsg.textContent()) ?? ''; expect(text, 'message bubble should contain HH:MM timestamp').toMatch(/\d{1,2}[:h]\d{2}/); // Author label is uppercased and either "YOU" or the username. expect(text.length).toBeGreaterThan(0); }); test('long messages (>1000 chars) are accepted by the input without truncation', async ({ page }) => { const selected = await selectFirstConversation(page); if (!selected) await createRoom(page); const longText = 'a'.repeat(1200); const input = messageInput(page); await input.fill(longText); // input is a plain with no maxLength — it must hold the full string. const value = await input.inputValue(); expect(value.length).toBe(1200); // Send button is enabled. await expect(sendButton(page)).toBeEnabled(); }); test('special characters (`; const input = messageInput(page); await input.fill(`${xss} éàü 🎵 中文`); await sendButton(page).click(); // Whether the message actually sends over WS or not, the text must NEVER // become executable script. We check a global flag that would be set by the injected script. const xssFired = await page.evaluate( () => (window as unknown as { __xss_fired?: boolean }).__xss_fired === true, ); expect(xssFired, 'XSS payload was executed — message rendering is unsafe!').toBe(false); // The