import { expect, test } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; test.beforeEach(async ({ page }) => { await installMockBridge(page); }); test("send a message and see it in timeline", async ({ page }) => { const message = `Hello timeline ${Date.now()}`; await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-input").fill(message); await page.getByTestId("send-message").click(); await expect(page.getByTestId("message-timeline")).toContainText(message); await expect(page.getByTestId("message-row").last()).toContainText( "npub1mock...", ); }); test("send multiple messages in sequence", async ({ page }) => { const ts = Date.now(); const messages = [ `First message ${ts}`, `Second message ${ts}`, `Third message ${ts}`, ]; const input = page.getByTestId("message-input"); const sendButton = page.getByTestId("send-message"); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); for (const message of messages) { await input.fill(message); await sendButton.click(); await expect(page.getByTestId("message-timeline")).toContainText(message); } const timeline = page.getByTestId("message-timeline"); for (const message of messages) { await expect(timeline).toContainText(message); } }); test("message input clears after send", async ({ page }) => { const message = `Clear after send ${Date.now()}`; const input = page.getByTestId("message-input"); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await input.fill(message); await expect(input).toHaveText(message); await page.getByTestId("send-message").click(); await expect(page.getByTestId("message-timeline")).toContainText(message); await expect(input).toHaveText(""); }); test("emoji picker inserts emoji into the draft and keeps focus in the composer", async ({ page, }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const input = page.getByTestId("message-input"); await input.fill("Ship"); await page.getByTestId("composer-emoji-button").click(); // emoji-mart renders inside a Shadow DOM web component — use the search // input to find the rocket emoji, then click it. const pickerEl = page.locator("em-emoji-picker"); const searchInput = pickerEl.locator("input[type='search']"); await searchInput.fill("rocket"); await pickerEl.locator("button[aria-label='🚀']").first().click(); await expect(input).toHaveText("Ship🚀"); await expect(input).toBeFocused(); await input.pressSequentially(" now"); await expect(input).toHaveText("Ship🚀 now"); }); test("empty message cannot be sent", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const sendButton = page.getByTestId("send-message"); await expect(sendButton).toBeDisabled(); }); test("send message with Enter key", async ({ page }) => { const message = `Enter key send ${Date.now()}`; const input = page.getByTestId("message-input"); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await input.fill(message); await input.press("Enter"); await expect(page.getByTestId("message-timeline")).toContainText(message); }); test("messages persist across channel switches", async ({ page }) => { const message = `Persist across switch ${Date.now()}`; await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-input").fill(message); await page.getByTestId("send-message").click(); await expect(page.getByTestId("message-timeline")).toContainText(message); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText(message); }); test("draft is preserved when switching channels", async ({ page }) => { const draft = `Unsent draft ${Date.now()}`; const input = page.getByTestId("message-input"); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); // Type a draft but do not send it await input.fill(draft); await expect(input).toHaveText(draft); // Switch to another channel — composer should be empty await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await expect(input).toHaveText(""); // Switch back — the draft should still be there await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(input).toHaveText(draft); }); test("sending a message clears the draft", async ({ page }) => { const message = `Sent message ${Date.now()}`; const input = page.getByTestId("message-input"); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); // Type and send a message await input.fill(message); await page.getByTestId("send-message").click(); await expect(page.getByTestId("message-timeline")).toContainText(message); // Switch away and back — composer should be empty, not restored from draft await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(input).toHaveText(""); }); test("different channels have independent messages", async ({ page }) => { const ts = Date.now(); const generalMessage = `General only ${ts}`; const randomMessage = `Random only ${ts}`; await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-input").fill(generalMessage); await page.getByTestId("send-message").click(); await expect(page.getByTestId("message-timeline")).toContainText( generalMessage, ); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await expect(page.getByTestId("message-timeline")).not.toContainText( generalMessage, ); await page.getByTestId("message-input").fill(randomMessage); await page.getByTestId("send-message").click(); await expect(page.getByTestId("message-timeline")).toContainText( randomMessage, ); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( generalMessage, ); await expect(page.getByTestId("message-timeline")).not.toContainText( randomMessage, ); }); test("day divider appears in timeline", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( "Welcome to #general", ); await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible(); }); test("send message to DM channel", async ({ page }) => { const message = `DM message ${Date.now()}`; await page.goto("/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); await page.getByTestId("message-input").fill(message); await page.getByTestId("send-message").click(); await expect(page.getByTestId("message-timeline")).toContainText(message); }); test("shows your avatar on your own message when profile avatar is set", async ({ page, }) => { const message = `Avatar message ${Date.now()}`; const avatarUrl = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E'; await page.goto("/"); await openSettings(page, "profile"); await page.getByTestId("profile-avatar-url").fill(avatarUrl); await page.getByTestId("profile-save").click(); await page.getByTestId("settings-close").click(); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-input").fill(message); await page.getByTestId("send-message").click(); const lastMessage = page.getByTestId("message-row").last(); await expect(lastMessage).toContainText(message); await expect(lastMessage.getByTestId("message-avatar-image")).toHaveAttribute( "src", avatarUrl, ); }); test("opens a single-level thread panel with inline expansion", async ({ page, }) => { const timestamp = Date.now(); const firstReply = `First threaded reply ${timestamp}`; const siblingReply = `Sibling threaded reply ${timestamp}`; const nestedReply = `Nested threaded reply ${timestamp}`; const nestedReplyFromBob = `Nested reply from Bob ${timestamp}`; const fillerReplies = Array.from( { length: 14 }, (_, index) => `Thread filler reply ${index} ${timestamp}`, ); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( "Welcome to #general", ); const timeline = page.getByTestId("message-timeline"); const timelineRows = timeline.getByTestId("message-row"); const threadPanel = page.getByTestId("message-thread-panel"); const threadBody = threadPanel.getByTestId("message-thread-body"); const threadComposer = threadPanel.locator('[data-testid="message-input"]'); const threadSendButton = threadPanel.getByTestId("send-message"); const threadReplies = threadPanel.getByTestId("message-thread-replies"); const rootMessage = timelineRows.first(); const rootMessageId = await rootMessage.getAttribute("data-message-id"); if (!rootMessageId) { throw new Error("Expected root message row to have a data-message-id."); } const rootSummaryRow = timeline.locator( `[data-thread-head-id="${rootMessageId}"]`, ); await rootMessage.hover(); await rootMessage.getByRole("button", { name: "Reply" }).click(); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( "Welcome to #general", ); await threadComposer.fill(firstReply); await threadSendButton.click(); await expect(threadReplies).toContainText(firstReply); await threadComposer.fill(siblingReply); await threadSendButton.click(); await expect(threadReplies).toContainText(siblingReply); for (const fillerReply of fillerReplies) { await threadComposer.fill(fillerReply); await threadSendButton.click(); await expect(threadReplies).toContainText(fillerReply); } await expect .poll(async () => { return threadBody.evaluate((element) => { const body = element as HTMLDivElement; return body.scrollHeight - body.clientHeight; }); }) .toBeGreaterThan(160); await expect( timeline.getByTestId("message-row").filter({ hasText: firstReply }), ).toHaveCount(0); await expect( timeline.getByTestId("message-row").filter({ hasText: siblingReply }), ).toHaveCount(0); await expect(rootSummaryRow).toContainText("16 replies"); await expect( rootSummaryRow.getByTestId("message-thread-summary-participant"), ).toHaveCount(1); await threadPanel.getByTestId("message-thread-close").click(); await expect(threadPanel).toBeHidden(); await rootSummaryRow.click(); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( "Welcome to #general", ); const firstReplyRow = threadReplies .getByTestId("message-row") .filter({ hasText: firstReply }) .first(); await firstReplyRow.hover(); await firstReplyRow.getByRole("button", { name: "Reply" }).click(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( "Welcome to #general", ); await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0); await threadComposer.fill(nestedReply); await threadSendButton.click(); const nestedReplyRow = threadReplies .getByTestId("message-row") .filter({ hasText: nestedReply }) .first(); await expect(nestedReplyRow).toBeVisible(); await expect( timeline.getByTestId("message-row").filter({ hasText: nestedReply }), ).toHaveCount(0); await expect( threadReplies.getByTestId("message-row").filter({ hasText: siblingReply }), ).toHaveCount(1); await expect .poll(async () => { return nestedReplyRow.evaluate((row) => { const threadBody = row.closest( '[data-testid="message-thread-body"]', ) as HTMLElement | null; if (!threadBody) { return Number.POSITIVE_INFINITY; } const rowRect = row.getBoundingClientRect(); const bodyRect = threadBody.getBoundingClientRect(); return rowRect.top - bodyRect.top; }); }) .toBeLessThan(160); const firstReplyId = await firstReplyRow.getAttribute("data-message-id"); if (!firstReplyId) { throw new Error("Expected first reply row to have a data-message-id."); } await page.evaluate( ({ content, parentEventId, pubkey }) => { window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content, parentEventId, pubkey, }); }, { content: nestedReplyFromBob, parentEventId: firstReplyId, pubkey: TEST_IDENTITIES.bob.pubkey, }, ); const nestedReplyFromBobRow = threadReplies .getByTestId("message-row") .filter({ hasText: nestedReplyFromBob }) .first(); await expect(nestedReplyFromBobRow).toBeVisible(); const firstReplySummaryRow = threadReplies.locator( `[data-thread-head-id="${firstReplyId}"]`, ); await expect(firstReplySummaryRow).toHaveCount(0); await expect(rootSummaryRow).toContainText("18 replies"); await expect( rootSummaryRow.getByTestId("message-thread-summary-participant"), ).toHaveCount(2); await expect .poll(async () => { return nestedReplyRow.evaluate((row) => { const threadBody = row.closest( '[data-testid="message-thread-body"]', ) as HTMLElement | null; if (!threadBody) { return Number.POSITIVE_INFINITY; } const rowRect = row.getBoundingClientRect(); const bodyRect = threadBody.getBoundingClientRect(); return rowRect.top - bodyRect.top; }); }) .toBeLessThan(160); }); test("thread panel width uses session storage and reset handle", async ({ page, }) => { const customWidthPx = 520; const defaultWidthPx = 380; await page.addInitScript((width) => { window.sessionStorage.setItem( "sprout.desktop.thread-panel-width", String(width), ); }, customWidthPx); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); const rootMessage = timeline.getByTestId("message-row").first(); const threadPanel = page.getByTestId("message-thread-panel"); const resizeHandle = threadPanel.getByTestId("message-thread-resize-handle"); await rootMessage.hover(); await rootMessage.getByRole("button", { name: "Reply" }).click(); await expect(threadPanel).toBeVisible(); await expect .poll(async () => { return threadPanel.evaluate((panel) => { const element = panel as HTMLElement; return Math.round(element.getBoundingClientRect().width); }); }) .toBe(customWidthPx); await resizeHandle.dblclick(); await expect .poll(async () => { return threadPanel.evaluate((panel) => { const element = panel as HTMLElement; return Math.round(element.getBoundingClientRect().width); }); }) .toBe(defaultWidthPx); await threadPanel.getByTestId("message-thread-close").click(); await expect(threadPanel).toBeHidden(); await rootMessage.hover(); await rootMessage.getByRole("button", { name: "Reply" }).click(); await expect(threadPanel).toBeVisible(); await expect .poll(async () => { return threadPanel.evaluate((panel) => { const element = panel as HTMLElement; return Math.round(element.getBoundingClientRect().width); }); }) .toBe(defaultWidthPx); });