From 1813923a727bd38967e23ae44eb45d1d8600271c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 11 Sep 2026 13:28:37 -0400 Subject: [PATCH] fix(desktop): keep buffered messages reachable Keep the "Jump to latest" recovery visible whenever buffered messages are waiting, including at the physical bottom of the frozen message model, so releasing them and scrolling to newest is always available. Parameterize the messaging spec so ordinary sharing and a native wheel-return frozen-tail recovery both stay covered, replace an ambiguous mention through the native select-all before picking the exact recipient, and witness authored deletion in the remote-owned spec through the same native command instead of clearing the editor. Send-flow failure handling and its error formatter are unchanged from the landed integration base. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../features/messages/ui/MessageTimeline.tsx | 3 +- desktop/tests/e2e/mention-recipients.spec.ts | 16 + desktop/tests/e2e/messaging.spec.ts | 657 ++++++++++-------- .../tests/e2e/remote-owned-mentions.spec.ts | 14 +- 4 files changed, 402 insertions(+), 288 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 88ec8080aab..f650178e950 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -875,7 +875,8 @@ const MessageTimelineBase = React.forwardRef< )} - {!isAtBottom ? ( + {/* Physical bottom may be the frozen tail, not the newest message. */} + {!isAtBottom || bufferedTimeline.pendingCount > 0 ? (
window.getSelection()?.toString()), + ).toBe("@Scout hello"); + await page.keyboard.insertText("@Scout"); + await page.getByTestId(`mention-suggestion-${FIRST}`).click(); + await page.keyboard.type("hello"); + await expect(input).toHaveText("@Scout hello"); + await page.getByTestId("send-message").click(); + await expect + .poll(() => recipients(page, "@Scout hello")) + .toEqual([[FIRST]]); + await expect(input).toHaveText(""); + } }); } diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 67641d1c38e..66f3f6ccc12 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -12,6 +12,25 @@ const LINK_PREVIEW_IMAGE = readFileSync( ); const LINK_PREVIEW_IMAGE_DATA_URL = `data:image/png;base64,${LINK_PREVIEW_IMAGE.toString("base64")}`; +async function replaceThreadEdit( + input: Locator, + source: string, + replacement: string, +) { + // The Edit action must seed this scoped composer and deliver its initial focus. + // Focus is not an idle fence: later PM focus callbacks project the current + // selection. Mod+A commits AllSelection through Tiptap, unlike DOM-only fill. + await expect(input).toHaveText(source); + await expect(input).toBeFocused(); + await input.press("ControlOrMeta+a"); + expect(await input.evaluate(() => window.getSelection()?.toString())).toBe( + source, + ); + await input.page().keyboard.insertText(replacement); + // Observe the first replacement before any refused-navigation action. + expect(await input.textContent()).toBe(replacement); +} + async function waitForReadyComposerSnapshots( page: import("@playwright/test").Page, count = 1, @@ -1707,13 +1726,12 @@ test("send multiple messages in sequence", async ({ page }) => { 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.context().grantPermissions(["clipboard-read", "clipboard-write"], { + origin: new URL(page.url()).origin, + }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1750,16 +1768,15 @@ test("copy a rendered code block and paste it back as code", async ({ 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.context().grantPermissions(["clipboard-read", "clipboard-write"], { + origin: new URL(page.url()).origin, + }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1794,11 +1811,10 @@ test("pasting a long copied code block scrolls composer to cursor", async ({ test("code block shows language label when language is specified", async ({ page, }) => { + await page.goto("/"); await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { - origin: "http://127.0.0.1:4173", + origin: new URL(page.url()).origin, }); - - await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2434,285 +2450,354 @@ test("send message to DM channel p-tags the recipient", async ({ page }) => { .toContainEqual(["p", TEST_IDENTITIES.alice.pubkey]); }); -test("sends a thread message to its parent channel with a root-thread link", async ({ - page, -}) => { - const timestamp = Date.now(); - const rootContent = `🧵 Share source thread ${timestamp}`; - const priorChannelMessage = `Prior channel message ${timestamp}`; - const replySummary = `Share this reply ${timestamp}`; - const attachmentSha = "d".repeat(64); - const attachmentUrl = `http://localhost:3000/media/${attachmentSha}.txt`; - const customEmojiUrl = "https://example.com/send-to-channel-party.svg"; - const previewUrl = "https://github.com/block/buzz/pull/5305"; - const ownReplyContent = [ - `${replySummary} with @alice :party:`, - `[launch-notes.txt](${attachmentUrl})`, - previewUrl, - ].join("\n\n"); - const imetaTag = [ - "imeta", - `url ${attachmentUrl}`, - "m text/plain", - `x ${attachmentSha}`, - "size 42", - "filename launch-notes.txt", - ]; - const emojiTag = ["emoji", "party", customEmojiUrl]; - const mentionTag = ["mention", TEST_IDENTITIES.alice.pubkey]; - const linkPreviewTag = [ - "link-preview", - "snapshot", - "1", - previewUrl, - "Add Send to channel for thread messages", - "GitHub", - "A shared link preview preserved from the source thread message.", - "", - "", - "", - "", - ]; +for (const recovery of [false, true]) { + test( + recovery + ? "wheel return exposes stranded shared reply recovery before panel close" + : "sends a thread message to its parent channel with a root-thread link", + async ({ page }) => { + const timestamp = Date.now(); + const rootContent = `🧵 Share source thread ${timestamp}`; + const priorChannelMessage = `Prior channel message ${timestamp}`; + const replySummary = `Share this reply ${timestamp}`; + const attachmentSha = "d".repeat(64); + const attachmentUrl = `http://localhost:3000/media/${attachmentSha}.txt`; + const customEmojiUrl = "https://example.com/send-to-channel-party.svg"; + const previewUrl = "https://github.com/block/buzz/pull/5305"; + const ownReplyContent = [ + `${replySummary} with @alice :party:`, + `[launch-notes.txt](${attachmentUrl})`, + previewUrl, + ].join("\n\n"); + const imetaTag = [ + "imeta", + `url ${attachmentUrl}`, + "m text/plain", + `x ${attachmentSha}`, + "size 42", + "filename launch-notes.txt", + ]; + const emojiTag = ["emoji", "party", customEmojiUrl]; + const mentionTag = ["mention", TEST_IDENTITIES.alice.pubkey]; + const linkPreviewTag = [ + "link-preview", + "snapshot", + "1", + previewUrl, + "Add Send to channel for thread messages", + "GitHub", + "A shared link preview preserved from the source thread message.", + "", + "", + "", + "", + ]; + + await page.route(customEmojiUrl, (route) => + route.fulfill({ + body: '', + contentType: "image/svg+xml", + }), + ); - await page.route(customEmojiUrl, (route) => - route.fulfill({ - body: '', - contentType: "image/svg+xml", - }), - ); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + (window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }) ?? + false), + ); - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - await page.waitForFunction( - () => - typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && - (window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ - channelName: "general", - }) ?? - false), - ); + const { ownReplyId, rootId } = await page.evaluate( + ({ alicePubkey, ownReply, root, semanticTags }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const rootEvent = emit({ + channelName: "general", + content: root, + pubkey: alicePubkey, + }); + const ownReplyEvent = emit({ + channelName: "general", + content: ownReply, + extraTags: semanticTags, + mentionPubkeys: [alicePubkey], + parentEventId: rootEvent.id, + }); + return { + ownReplyId: ownReplyEvent.id, + rootId: rootEvent.id, + }; + }, + { + alicePubkey: TEST_IDENTITIES.alice.pubkey, + ownReply: ownReplyContent, + root: rootContent, + semanticTags: [imetaTag, emojiTag, mentionTag, linkPreviewTag], + }, + ); - const { ownReplyId, rootId } = await page.evaluate( - ({ alicePubkey, ownReply, root, semanticTags }) => { - const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; - if (!emit) throw new Error("Mock message emitter is unavailable."); - const rootEvent = emit({ - channelName: "general", - content: root, - pubkey: alicePubkey, - }); - const ownReplyEvent = emit({ - channelName: "general", - content: ownReply, - extraTags: semanticTags, - mentionPubkeys: [alicePubkey], - parentEventId: rootEvent.id, + const timeline = page.getByTestId("message-timeline"); + const rootRow = timeline.locator(`[data-message-id="${rootId}"]`); + await expect(rootRow).toContainText(rootContent); + + await page.getByTestId("message-input").fill(priorChannelMessage); + await page.getByTestId("send-message").click(); + const priorChannelRow = timeline + .getByTestId("message-row") + .filter({ hasText: priorChannelMessage }); + await expect(priorChannelRow).toBeVisible(); + await expect( + priorChannelRow.getByTestId("message-send-status"), + ).toHaveCount(0); + + await timeline + .locator( + `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`, + ) + .click(); + const threadPanel = page.getByTestId("message-thread-panel"); + const threadRootRow = threadPanel.locator( + `[data-message-id="${rootId}"]`, + ); + const rootMoreActions = threadRootRow.getByTestId( + `more-actions-${rootId}`, + ); + await threadRootRow.hover(); + await rootMoreActions.click(); + await expect(page.getByRole("menu")).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Send to channel" }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + + let scrollBox: { x: number; y: number } | null = null; + if (recovery) { + // Read history before sharing, then return with native input rather than + // relying on panel-close reflow to release the semantic tail buffer. + await page.setViewportSize({ width: 1000, height: 500 }); + scrollBox = await timeline.boundingBox(); + if (!scrollBox) throw new Error("Missing timeline geometry"); + await page.mouse.move(scrollBox.x + 40, scrollBox.y + 60); + await page.mouse.wheel(0, -600); + await expect( + page.getByTestId("message-scroll-to-latest"), + ).toBeVisible(); + } + if (!recovery) { + await expect + .poll(() => + timeline.evaluate( + (element) => + element.scrollHeight - element.clientHeight - element.scrollTop, + ), + ) + .toBeLessThanOrEqual(32); + } + const ownReplyRow = threadPanel.locator( + `[data-message-id="${ownReplyId}"]`, + ); + await expect(ownReplyRow).toContainText(replySummary); + await ownReplyRow + .getByTestId(`more-actions-${ownReplyId}`) + .press("Enter"); + const sendToChannelItem = page.getByRole("menuitem", { + name: "Send to channel", }); - return { - ownReplyId: ownReplyEvent.id, - rootId: rootEvent.id, - }; - }, - { - alicePubkey: TEST_IDENTITIES.alice.pubkey, - ownReply: ownReplyContent, - root: rootContent, - semanticTags: [imetaTag, emojiTag, mentionTag, linkPreviewTag], - }, - ); - - const timeline = page.getByTestId("message-timeline"); - const rootRow = timeline.locator(`[data-message-id="${rootId}"]`); - await expect(rootRow).toContainText(rootContent); - - await page.getByTestId("message-input").fill(priorChannelMessage); - await page.getByTestId("send-message").click(); - const priorChannelRow = timeline - .getByTestId("message-row") - .filter({ hasText: priorChannelMessage }); - await expect(priorChannelRow).toBeVisible(); - await expect(priorChannelRow.getByTestId("message-send-status")).toHaveCount( - 0, - ); - - await timeline - .locator( - `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`, - ) - .click(); - const threadPanel = page.getByTestId("message-thread-panel"); - const threadRootRow = threadPanel.locator(`[data-message-id="${rootId}"]`); - const rootMoreActions = threadRootRow.getByTestId(`more-actions-${rootId}`); - await rootMoreActions.click({ force: true }); - await expect(page.getByRole("menu")).toBeVisible(); - await expect( - page.getByRole("menuitem", { name: "Send to channel" }), - ).toHaveCount(0); - await page.keyboard.press("Escape"); - await expect(page.getByRole("menu")).toHaveCount(0); - - const ownReplyRow = threadPanel.locator(`[data-message-id="${ownReplyId}"]`); - await expect(ownReplyRow).toContainText(replySummary); - await ownReplyRow - .getByTestId(`more-actions-${ownReplyId}`) - .click({ force: true }); - const sendToChannelItem = page.getByRole("menuitem", { - name: "Send to channel", - }); - const sendToChannelIcon = sendToChannelItem.getByTestId( - "send-to-channel-icon", - ); - await expect(sendToChannelIcon).toBeVisible(); - await expect(sendToChannelIcon).toHaveAttribute("aria-hidden", "true"); - await expect(sendToChannelIcon).toHaveClass(/lucide-hash-arrow-in/); - await expect - .poll(async () => { - const box = await sendToChannelIcon.boundingBox(); - return box ? [box.width, box.height] : null; - }) - .toEqual([16, 16]); - await sendToChannelItem.click(); - - await expect( - page.locator("[data-sonner-toast]").filter({ hasText: "Sent to channel" }), - ).toBeVisible(); - await expect - .poll(() => - page.evaluate((content) => { - return Boolean( + const sendToChannelIcon = sendToChannelItem.getByTestId( + "send-to-channel-icon", + ); + await expect(sendToChannelIcon).toBeVisible(); + await expect(sendToChannelIcon).toHaveAttribute("aria-hidden", "true"); + await expect(sendToChannelIcon).toHaveClass(/lucide-hash-arrow-in/); + await expect + .poll(async () => { + const box = await sendToChannelIcon.boundingBox(); + return box ? [box.width, box.height] : null; + }) + .toEqual([16, 16]); + await sendToChannelItem.click(); + + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Sent to channel" }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate((content) => { + return Boolean( + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast( + (entry) => + entry.command === "send_channel_message" && + (entry.payload as { content?: string } | undefined) + ?.content === content, + ), + ); + }, ownReplyContent), + ) + .toBe(true); + const sentPayload = await page.evaluate( + (content) => (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast( (entry) => entry.command === "send_channel_message" && (entry.payload as { content?: string } | undefined)?.content === content, - ), - ); - }, ownReplyContent), - ) - .toBe(true); - const sentPayload = await page.evaluate( - (content) => - (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast( - (entry) => - entry.command === "send_channel_message" && - (entry.payload as { content?: string } | undefined)?.content === - content, - )?.payload as Record | undefined, - ownReplyContent, - ); - expect(sentPayload).toMatchObject({ - channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", - content: ownReplyContent, - emojiTags: [emojiTag], - linkPreviewTags: [linkPreviewTag], - mediaTags: [imetaTag], - mentionPubkeys: [TEST_IDENTITIES.alice.pubkey], - mentionTags: [mentionTag], - parentEventId: null, - sentFromThreadTag: ["buzz:sent-from-thread", rootId, rootContent], - }); - - await page.getByTestId("auxiliary-panel-close").click(); - - const sharedRow = timeline - .getByTestId("message-row") - .filter({ hasText: replySummary }) - .last(); - await expect - .poll(async () => { - if (await sharedRow.isVisible()) return true; - const scrollToLatest = page.getByTestId("message-scroll-to-latest"); - if (await scrollToLatest.isVisible()) await scrollToLatest.click(); - return false; - }) - .toBe(true); - await expect(sharedRow.getByTestId("message-author")).toHaveText( - "npub1mock...", - ); - await expect(sharedRow.getByTestId("message-avatar-fallback")).toBeVisible(); - await expect(sharedRow.locator('[data-mention=""]')).toContainText("alice"); - await expect(sharedRow.locator("img[data-custom-emoji]")).toHaveAttribute( - "src", - customEmojiUrl, - ); - await expect(sharedRow.getByTestId("file-card")).toContainText( - "launch-notes.txt", - ); - await expect( - sharedRow.locator('[data-link-preview="github-pull-request"]'), - ).toContainText("Add Send to channel for thread messages"); - const sourceLine = sharedRow.getByTestId("sent-from-thread"); - await expect(sourceLine).toContainText("Sent from thread:"); - await expect(sourceLine).toHaveClass(/message-markdown/); - await expect(sourceLine).toHaveClass(/pt-0\.5/); - await expect(sourceLine).toHaveClass(/text-sm/); - await expect(sourceLine).toHaveClass(/font-normal/); - await expect(sourceLine).toHaveClass(/leading-4/); - await expect(sourceLine).toHaveClass(/text-muted-foreground\/70/); - const rootLink = sourceLine.locator("[data-message-link]"); - const sourcePrefix = sourceLine.locator("span").first(); - const rootLinkLabel = rootContent; - await expect(rootLink).toHaveText(rootLinkLabel); - await expect(rootLink).toHaveAttribute( - "aria-label", - "Open thread in general", - ); - await expect(rootLink).toHaveAttribute("title", rootLinkLabel); - await expect(rootLink).toHaveClass(/max-w-80/); - await expect(rootLink).toHaveClass(/truncate/); - await expect(rootLink).toHaveClass(/inline-block/); - await expect(rootLink).toHaveClass(/font-medium/); - await expect(rootLink).not.toHaveClass(/mention-chip/); - await expect(rootLink).not.toHaveClass(/border-b/); - const rootLinkText = rootLink.locator("[data-message-link-text]"); - const rootLinkEmoji = rootLink.locator("[data-message-link-emoji]"); - await expect(rootLinkText).toHaveText(` Share source thread ${timestamp}`); - await expect(rootLinkText).not.toHaveClass(/border-b/); - await expect(rootLinkEmoji).toHaveText("🧵"); - await expect(rootLinkEmoji).not.toHaveClass(/border-b/); - await expect(rootLink).not.toHaveAttribute("data-hovered"); - const [prefixColor, linkColorBeforeHover] = await Promise.all([ - sourcePrefix.evaluate((element) => getComputedStyle(element).color), - rootLink.evaluate((element) => getComputedStyle(element).color), - ]); - expect(linkColorBeforeHover).not.toBe(prefixColor); - await expect - .poll(() => - rootLink.evaluate((element) => getComputedStyle(element).backgroundColor), - ) - .toBe("rgba(0, 0, 0, 0)"); - await expect - .poll(() => - rootLinkText.evaluate((element) => getComputedStyle(element).boxShadow), - ) - .toBe("none"); - - await rootLink.hover(); - await expect(rootLink).toHaveAttribute("data-hovered", ""); - await expect - .poll(() => rootLink.evaluate((element) => getComputedStyle(element).color)) - .toBe(linkColorBeforeHover); - await expect - .poll(() => - rootLinkText.evaluate( - (element) => getComputedStyle(element).boxShadow !== "none", - ), - ) - .toBe(true); + )?.payload as Record | undefined, + ownReplyContent, + ); + expect(sentPayload).toMatchObject({ + channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + content: ownReplyContent, + emojiTags: [emojiTag], + linkPreviewTags: [linkPreviewTag], + mediaTags: [imetaTag], + mentionPubkeys: [TEST_IDENTITIES.alice.pubkey], + mentionTags: [mentionTag], + parentEventId: null, + sentFromThreadTag: ["buzz:sent-from-thread", rootId, rootContent], + }); - await expect - .poll(() => - rootLinkEmoji.evaluate((element) => getComputedStyle(element).boxShadow), - ) - .toBe("none"); + if (recovery) { + if (!scrollBox) throw new Error("Missing recovery timeline geometry"); + const latest = page.getByTestId("message-scroll-to-latest"); + await expect(latest).toBeVisible(); + await page.mouse.move(scrollBox.x + 40, scrollBox.y + 60); + await page.mouse.wheel(0, 600); + // A real return to the frozen model's floor must not hide pending work. + await expect + .poll(() => + timeline.evaluate( + (element) => + element.scrollHeight - element.clientHeight - element.scrollTop, + ), + ) + .toBe(0); + await expect(latest).toBeVisible(); + await latest.click(); + } + await page.getByTestId("auxiliary-panel-close").click(); + + const sharedRow = timeline + .getByTestId("message-row") + .filter({ hasText: replySummary }) + .last(); + await expect + .poll(async () => { + if (await sharedRow.isVisible()) return true; + const scrollToLatest = page.getByTestId("message-scroll-to-latest"); + if (await scrollToLatest.isVisible()) await scrollToLatest.click(); + return false; + }) + .toBe(true); + await expect(sharedRow.getByTestId("message-author")).toHaveText( + "npub1mock...", + ); + await expect( + sharedRow.getByTestId("message-avatar-fallback"), + ).toBeVisible(); + await expect(sharedRow.locator('[data-mention=""]')).toContainText( + "alice", + ); + await expect(sharedRow.locator("img[data-custom-emoji]")).toHaveAttribute( + "src", + customEmojiUrl, + ); + await expect(sharedRow.getByTestId("file-card")).toContainText( + "launch-notes.txt", + ); + await expect( + sharedRow.locator('[data-link-preview="github-pull-request"]'), + ).toContainText("Add Send to channel for thread messages"); + const sourceLine = sharedRow.getByTestId("sent-from-thread"); + await expect(sourceLine).toContainText("Sent from thread:"); + await expect(sourceLine).toHaveClass(/message-markdown/); + await expect(sourceLine).toHaveClass(/pt-0\.5/); + await expect(sourceLine).toHaveClass(/text-sm/); + await expect(sourceLine).toHaveClass(/font-normal/); + await expect(sourceLine).toHaveClass(/leading-4/); + await expect(sourceLine).toHaveClass(/text-muted-foreground\/70/); + const rootLink = sourceLine.locator("[data-message-link]"); + const sourcePrefix = sourceLine.locator("span").first(); + const rootLinkLabel = rootContent; + await expect(rootLink).toHaveText(rootLinkLabel); + await expect(rootLink).toHaveAttribute( + "aria-label", + "Open thread in general", + ); + await expect(rootLink).toHaveAttribute("title", rootLinkLabel); + await expect(rootLink).toHaveClass(/max-w-80/); + await expect(rootLink).toHaveClass(/truncate/); + await expect(rootLink).toHaveClass(/inline-block/); + await expect(rootLink).toHaveClass(/font-medium/); + await expect(rootLink).not.toHaveClass(/mention-chip/); + await expect(rootLink).not.toHaveClass(/border-b/); + const rootLinkText = rootLink.locator("[data-message-link-text]"); + const rootLinkEmoji = rootLink.locator("[data-message-link-emoji]"); + await expect(rootLinkText).toHaveText( + ` Share source thread ${timestamp}`, + ); + await expect(rootLinkText).not.toHaveClass(/border-b/); + await expect(rootLinkEmoji).toHaveText("🧵"); + await expect(rootLinkEmoji).not.toHaveClass(/border-b/); + await expect(rootLink).not.toHaveAttribute("data-hovered"); + const [prefixColor, linkColorBeforeHover] = await Promise.all([ + sourcePrefix.evaluate((element) => getComputedStyle(element).color), + rootLink.evaluate((element) => getComputedStyle(element).color), + ]); + expect(linkColorBeforeHover).not.toBe(prefixColor); + await expect + .poll(() => + rootLink.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ), + ) + .toBe("rgba(0, 0, 0, 0)"); + await expect + .poll(() => + rootLinkText.evaluate( + (element) => getComputedStyle(element).boxShadow, + ), + ) + .toBe("none"); + + await rootLink.hover(); + await expect(rootLink).toHaveAttribute("data-hovered", ""); + await expect + .poll(() => + rootLink.evaluate((element) => getComputedStyle(element).color), + ) + .toBe(linkColorBeforeHover); + await expect + .poll(() => + rootLinkText.evaluate( + (element) => getComputedStyle(element).boxShadow !== "none", + ), + ) + .toBe(true); - await rootLink.click(); - await expect(threadPanel).toBeVisible(); - await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - rootContent, + await expect + .poll(() => + rootLinkEmoji.evaluate( + (element) => getComputedStyle(element).boxShadow, + ), + ) + .toBe("none"); + + await rootLink.click(); + await expect(threadPanel).toBeVisible(); + await expect( + threadPanel.getByTestId("message-thread-head"), + ).toContainText(rootContent); + }, ); -}); +} test("shows your avatar on your own message when profile avatar is set", async ({ page, @@ -3825,7 +3910,7 @@ test("closing a thread while editing a reply preserves the typed edit", async ({ await threadReply.hover(); await threadReply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); - await threadInput.fill(edited); + await replaceThreadEdit(threadInput, reply, edited); await threadPanel.getByTestId("auxiliary-panel-close").click(); @@ -4031,7 +4116,7 @@ test("a refused message deep link retries after the thread edit is canceled", as await threadReply.hover(); await threadReply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); - await threadInput.fill(`${reply} unsaved`); + await replaceThreadEdit(threadInput, reply, `${reply} unsaved`); const threadUrl = page.url(); expect(threadUrl).toContain( @@ -4115,7 +4200,7 @@ test("a refused sent-from-thread link preserves the edit and retries after cance await reply.hover(); await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); - await threadInput.fill(dirtyReply); + await replaceThreadEdit(threadInput, sourceReply, dirtyReply); const threadUrl = page.url(); expect(threadUrl).toContain(`thread=${sourceRootId}`); @@ -4189,7 +4274,7 @@ test("a refused search result preserves the edit and retries after cancel", asyn await reply.hover(); await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); - await threadInput.fill(dirtyReply); + await replaceThreadEdit(threadInput, sourceReply, dirtyReply); const threadUrl = page.url(); expect(threadUrl).toContain(`thread=${sourceRootId}`); @@ -4263,7 +4348,7 @@ test("a refused forum search result preserves the edit and retries after cancel" await reply.hover(); await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); - await threadInput.fill(dirtyReply); + await replaceThreadEdit(threadInput, sourceReply, dirtyReply); const threadUrl = page.url(); const editTarget = threadPanel.getByTestId("edit-target"); @@ -4346,7 +4431,7 @@ for (const targetKind of ["reply", "root"] as const) { await reply.hover(); await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); - await threadInput.fill(dirtyReply); + await replaceThreadEdit(threadInput, sourceReply, dirtyReply); const targetLink = timeline .getByTestId("message-row") @@ -4440,7 +4525,7 @@ test("a refused channel switch preserves the reply edit and retries after cancel await reply.hover(); await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); - await threadInput.fill(dirtyReply); + await replaceThreadEdit(threadInput, sourceReply, dirtyReply); const navigationBefore = await page.evaluate(() => ({ historyLength: history.length, @@ -4524,7 +4609,7 @@ for (const backInput of ["button", "keyboard"] as const) { await reply.hover(); await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); - await threadInput.fill(dirtyReply); + await replaceThreadEdit(threadInput, sourceReply, dirtyReply); const navigationBefore = await page.evaluate(() => ({ historyLength: history.length, diff --git a/desktop/tests/e2e/remote-owned-mentions.spec.ts b/desktop/tests/e2e/remote-owned-mentions.spec.ts index b9d5e9efb8d..15b711958ca 100644 --- a/desktop/tests/e2e/remote-owned-mentions.spec.ts +++ b/desktop/tests/e2e/remote-owned-mentions.spec.ts @@ -590,7 +590,19 @@ for (const { incoming, savedFirst } of [ }), }; }, roots[0]); - await input.fill(""); + await input.press("ControlOrMeta+a"); + expect( + await input.evaluate((el) => { + const editor = ( + el as HTMLElement & { editor: import("@tiptap/core").Editor } + ).editor; + return { + selection: editor.state.selection.toJSON(), + text: window.getSelection()?.toString(), + }; + }), + ).toEqual({ selection: { type: "all" }, text: "new authored text" }); + await input.press("Backspace"); const deleted = await deletion.evaluate(({ completed }) => completed); // No assertion/poll between action completion and outgoing-key cleanup. await navigate(roots[1]);