Files
buzz/desktop/tests/e2e/messaging.spec.ts

721 lines
24 KiB
TypeScript

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("copy a rendered code block and paste it back as code", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:4173",
});
const code = "# not a heading\nconst answer = 42;\n indented();";
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await page.evaluate(
(text) => navigator.clipboard.writeText(text),
`\`\`\`ts\n${code}\n\`\`\``,
);
await input.click();
await page.keyboard.press("ControlOrMeta+V");
await page.getByTestId("send-message").click();
const copiedCodeBlock = page.locator("pre", { hasText: code });
await expect(copiedCodeBlock).toHaveCount(1);
const copyButton = page.getByLabel("Copy code block");
await expect(copyButton).toHaveCSS("opacity", "0");
await copiedCodeBlock.hover();
await expect(copyButton).toHaveCSS("opacity", "1");
await copyButton.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(code);
await input.click();
await page.keyboard.press("ControlOrMeta+V");
await input.press("Enter");
await expect(copiedCodeBlock).toHaveCount(2);
});
test("pasting a long copied code block scrolls composer to cursor", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:4173",
});
const longCode = Array.from(
{ length: 48 },
(_, index) => `const line${index} = ${index};`,
).join("\n");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await page.evaluate(
(text) => navigator.clipboard.writeText(text),
`\`\`\`ts\n${longCode}\n\`\`\``,
);
await input.click();
await page.keyboard.press("ControlOrMeta+V");
await page.getByTestId("send-message").click();
const copiedCodeBlock = page.locator("pre", { hasText: longCode });
await expect(copiedCodeBlock).toHaveCount(1);
await copiedCodeBlock.hover();
await page.getByLabel("Copy code block").click();
await input.fill("typed before paste");
await page.keyboard.press("ControlOrMeta+V");
const scrollContainer = page.getByTestId("message-input-scroll");
await expect
.poll(() =>
scrollContainer.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
),
)
.toBeLessThanOrEqual(1);
});
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;
});
})
.toBeGreaterThanOrEqual(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;
});
})
.toBeLessThanOrEqual(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(1);
await expect(firstReplySummaryRow).toContainText("2 replies");
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;
});
})
.toBeLessThanOrEqual(160);
await firstReplySummaryRow.click();
await expect(
threadReplies.getByTestId("message-row").filter({ hasText: nestedReply }),
).toHaveCount(0);
await expect(
threadReplies
.getByTestId("message-row")
.filter({ hasText: nestedReplyFromBob }),
).toHaveCount(0);
});
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);
});
test("composer is focused after selecting a channel", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Without clicking the input, typing should land in the composer.
const input = page.getByTestId("message-input");
await expect(input).toBeFocused();
await page.keyboard.type("autofocus-on-channel-select");
await expect(input).toHaveText("autofocus-on-channel-select");
});
test("composer is focused after switching to a different channel", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
const input = page.getByTestId("message-input");
await expect(input).toBeFocused();
});
test("thread composer is focused after clicking the reply icon", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Seed a message to reply to.
const seed = `Thread autofocus seed ${Date.now()}`;
const mainInput = page.getByTestId("message-input");
await mainInput.fill(seed);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(seed);
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const threadInput = threadPanel.getByTestId("message-input");
await expect(threadInput).toBeFocused();
await page.keyboard.type("typed-into-thread");
await expect(threadInput).toHaveText("typed-into-thread");
});
test("thread composer keeps focus after sending a thread reply", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Seed a root message we can open a thread on. At this point only one
// composer is mounted, so plain getByTestId is unambiguous.
const seed = `Thread focus-after-send seed ${Date.now()}`;
await page.getByTestId("message-input").fill(seed);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(seed);
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const threadInput = threadPanel.getByTestId("message-input");
await expect(threadInput).toBeFocused();
// Send a thread reply. After the send, `isSending` flips and back to false
// in both the main and thread composers; the thread input must keep focus.
const reply = `Thread reply ${Date.now()}`;
await page.keyboard.type(reply);
await expect(threadInput).toHaveText(reply);
await page.keyboard.press("Enter");
// Wait for the send to settle.
await expect(threadPanel).toContainText(reply);
// The thread input should still be focused — not the main composer.
// Both composers expose the same `message-input` data-testid, so we
// verify directly that `document.activeElement` lives inside the thread
// panel rather than the main pane.
const focusInThreadPanel = await page.evaluate(() => {
const panel = document.querySelector<HTMLElement>(
'[data-testid="message-thread-panel"]',
);
const active = document.activeElement as HTMLElement | null;
return Boolean(panel && active && panel.contains(active));
});
expect(focusInThreadPanel).toBe(true);
await expect(threadInput).toBeFocused();
});