diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts index b492b40ee2a..246cba89a4d 100644 --- a/desktop/tests/e2e/empty-edit-delete.spec.ts +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { openGeneral, pressRowAction } from "../helpers/channelHeadReady"; +import { clearComposer, replaceComposerText } from "../helpers/composer"; // A message the mock identity authors in #general. Editing/deleting one's own // message is exactly Sam's workflow: "delete a message by clearing its edit." @@ -16,9 +18,7 @@ async function openMoreActionsMenu( page: import("@playwright/test").Page, messageId: string, ) { - const row = page.locator(`[data-message-id="${messageId}"]`); - await row.hover(); - await page.getByTestId(`more-actions-${messageId}`).click(); + await pressRowAction(page.getByTestId(`more-actions-${messageId}`)); await expect(page.locator('[role="menuitem"]').first()).toBeVisible({ timeout: 5_000, }); @@ -37,17 +37,14 @@ async function submitEmptyEdit( // wait for it to populate before we clear it. const input = page.getByTestId("message-input"); await expect(input).not.toBeEmpty({ timeout: 5_000 }); - await input.click(); - await page.keyboard.press("ControlOrMeta+A"); - await page.keyboard.press("Backspace"); - await expect(input).toBeEmpty(); + await clearComposer(input); await page.keyboard.press("Enter"); } test.beforeEach(async ({ page }) => { await installMockBridge(page); await page.goto("/"); - await page.getByTestId("channel-general").click(); + await openGeneral(page); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect .poll(() => @@ -140,9 +137,7 @@ test("a non-empty edit still edits and never deletes", async ({ page }) => { await expect(input).not.toBeEmpty({ timeout: 5_000 }); const editedContent = `Edited, not deleted ${Date.now()}`; - await input.click(); - await page.keyboard.press("ControlOrMeta+A"); - await page.keyboard.type(editedContent); + await replaceComposerText(input, editedContent); await page.keyboard.press("Enter"); // No delete confirmation, edit mode exits, the row survives with new text. diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 9072513749e..e9f108a758b 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -3,17 +3,25 @@ import type { Locator, Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; +import { openGeneral, pressRowAction } from "../helpers/channelHeadReady"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; async function openMoreActionsMenu(page: Page, messageId: string) { const row = page.locator(`[data-message-id="${messageId}"]`); - await row.hover(); - await page.getByTestId(`more-actions-${messageId}`).click(); + await pressRowAction(row.getByTestId(`more-actions-${messageId}`)); await expect(page.locator('[role="menuitem"]').first()).toBeVisible({ timeout: 5_000, }); } +// Leave the bottom of the timeline with real wheel input. A programmatic +// scrollTop keeps the timeline's bottom intent armed, so the next content +// resize (the upload card mounting) pins it back down and unmounts the pill. +async function scrollTimelineUp(page: Page, timeline: Locator) { + await timeline.hover(); + await page.mouse.wheel(0, -2_000); +} + // Exercises the generic file-attachment UI contract end-to-end through the // mock Tauri bridge: paperclip upload → composer chip → send → FileCard in the // timeline. This guards the frontend wiring (the riskiest, previously @@ -166,7 +174,9 @@ test("opening edit during an immediate photo upload preserves the draft", async page, }) => { await page.goto("/"); - await page.getByTestId("channel-general").click(); + // Open the menu only after history and its head refresh settle, so the row + // is not replaced under the keyboard focus. + await openGeneral(page); await expect(page.getByTestId("chat-title")).toHaveText("general"); // Hold the upload at the IPC boundary until the edit assertion completes. // A fixed delay races menu interaction and CI scheduling, so it can test @@ -432,10 +442,7 @@ test("upload progress floats above the dock and lifts Jump to latest", async ({ const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); - await timeline.evaluate((element) => { - element.scrollTop = Math.max(500, element.scrollHeight / 2); - element.dispatchEvent(new Event("scroll", { bubbles: true })); - }); + await scrollTimelineUp(page, timeline); const jumpToLatest = page.getByTestId("message-scroll-to-latest"); await expect(jumpToLatest).toBeVisible(); const restingBox = await jumpToLatest.boundingBox(); @@ -444,29 +451,33 @@ test("upload progress floats above the dock and lifts Jump to latest", async ({ await page.getByTestId("send-message").click(); const uploadMotion = page.getByTestId("composer-upload-progress-motion"); await expect(uploadMotion).toBeVisible(); - await timeline.evaluate((element) => { - element.scrollTop = Math.max(500, element.scrollHeight / 2); - element.dispatchEvent(new Event("scroll", { bubbles: true })); - }); + await scrollTimelineUp(page, timeline); await expect(jumpToLatest).toBeVisible(); - await page.waitForTimeout(250); - - const [uploadBox, dockBackdropBox, liftedBox] = await Promise.all([ - uploadMotion.boundingBox(), - page.getByTestId("composer-dock-backdrop").boundingBox(), - jumpToLatest.boundingBox(), - ]); + // The upload card slides in and the pill lifts above it, both animated. + // Poll the settled layout instead of reading it after a fixed 250 ms. expect(restingBox).not.toBeNull(); - expect(uploadBox).not.toBeNull(); - expect(dockBackdropBox).not.toBeNull(); - expect(liftedBox).not.toBeNull(); - expect((dockBackdropBox?.y ?? 0) + 1).toBeGreaterThanOrEqual( - (uploadBox?.y ?? 0) + (uploadBox?.height ?? 0), - ); - expect((liftedBox?.y ?? 0) + (liftedBox?.height ?? 0)).toBeLessThanOrEqual( - uploadBox?.y ?? 0, - ); - expect(liftedBox?.y ?? 0).toBeLessThan((restingBox?.y ?? 0) - 10); + await expect + .poll(async () => { + const [uploadBox, dockBackdropBox, liftedBox] = await Promise.all([ + uploadMotion.boundingBox(), + page.getByTestId("composer-dock-backdrop").boundingBox(), + jumpToLatest.boundingBox(), + ]); + if (!uploadBox || !dockBackdropBox || !liftedBox || !restingBox) { + return "a box is missing"; + } + return { + uploadAboveDockBackdrop: + dockBackdropBox.y + 1 >= uploadBox.y + uploadBox.height, + pillAboveUpload: liftedBox.y + liftedBox.height <= uploadBox.y, + pillLifted: liftedBox.y < restingBox.y - 10, + }; + }) + .toEqual({ + uploadAboveDockBackdrop: true, + pillAboveUpload: true, + pillLifted: true, + }); await page.getByTestId("composer-upload-cancel").click(); }); diff --git a/desktop/tests/e2e/inbox-edit.spec.ts b/desktop/tests/e2e/inbox-edit.spec.ts index 69b094821e5..3b995517064 100644 --- a/desktop/tests/e2e/inbox-edit.spec.ts +++ b/desktop/tests/e2e/inbox-edit.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { clearComposer } from "../helpers/composer"; const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const CURRENT_PUBKEY = "deadbeef".repeat(8); @@ -158,8 +159,7 @@ async function submitEmptyEdit( await expect(detail.getByTestId("edit-target")).toBeVisible(); const input = detail.getByTestId("message-input"); await expect(input).not.toBeEmpty(); - await input.fill(""); - await expect(input).toBeEmpty(); + await clearComposer(input); await page.keyboard.press("Enter"); } diff --git a/desktop/tests/e2e/mention-recipients.spec.ts b/desktop/tests/e2e/mention-recipients.spec.ts index 0b81596780f..fc0ca29651b 100644 --- a/desktop/tests/e2e/mention-recipients.spec.ts +++ b/desktop/tests/e2e/mention-recipients.spec.ts @@ -2,6 +2,7 @@ import { expect, test, type Page } from "@playwright/test"; import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { replaceComposerText } from "../helpers/composer"; const FIRST = TEST_IDENTITIES.alice.pubkey; const SECOND = TEST_IDENTITIES.bob.pubkey; @@ -644,7 +645,7 @@ for (const replacement of [ // by filling the still-idle composer before the historical body is loaded. await expect(panel.getByTestId("edit-target")).toBeVisible(); await expect(input).toHaveText("@Scout historical ambiguity"); - await input.fill(replacement); + await replaceComposerText(input, replacement); await panel.getByTestId("send-message").click(); await expect(row).toContainText("repaired history"); const expected = replacement.startsWith("@") @@ -747,7 +748,7 @@ for (const mixed of [false, true]) { await expect(input).toHaveText( "@Scout and @Scout Jones historical ambiguity", ); - await input.fill(replacement); + await replaceComposerText(input, replacement); await panel.getByTestId("send-message").click(); await expect(row).toContainText("repaired history"); const expected = long; diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index afa8b9b6c3e..726ead05f80 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -4,6 +4,8 @@ import { expect, test, type Locator } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { openGeneral, pressRowAction } from "../helpers/channelHeadReady"; +import { replaceComposerText } from "../helpers/composer"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; import { openSettings } from "../helpers/settings"; @@ -28,7 +30,10 @@ async function expectReplyEditReady(threadPanel: Locator, content: string) { await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); const input = threadPanel.getByTestId("message-input"); await expect(input).toHaveText(content); - await expect(input).toBeFocused(); +} + +async function replyFromKeyboard(row: Locator) { + await pressRowAction(row.getByRole("button", { name: "Reply" })); } async function expectThreadReplyUnobscured(row: Locator) { @@ -3556,14 +3561,13 @@ test("editing a pre-seeded thread reply uses and focuses the thread composer", a { replyContent: reply, rootContent: root }, ); - await page.getByTestId("channel-general").click(); + await openGeneral(page); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timelineRoot = page .getByTestId("message-timeline") .locator(`[data-message-id="${rootId}"]`); await expect(timelineRoot).toContainText(root); - await timelineRoot.hover(); - await timelineRoot.getByRole("button", { name: "Reply" }).click(); + await replyFromKeyboard(timelineRoot); const threadPanel = page.getByTestId("message-thread-panel"); const threadInput = threadPanel.getByTestId("message-input"); @@ -3667,12 +3671,9 @@ test("editing a broadcast reply from a thread returns to the main composer", asy return { broadcastId: broadcastEvent.id, rootId: rootEvent.id }; }); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const timelineRoot = page.locator(`[data-message-id="${rootId}"]`); - await timelineRoot.hover(); - await timelineRoot - .getByRole("button", { name: "Reply" }) - .click({ force: true }); + await replyFromKeyboard(timelineRoot); const threadPanel = page.getByTestId("message-thread-panel"); const broadcastReply = threadPanel.locator( `[data-message-id="${broadcastId}"]`, @@ -3697,7 +3698,7 @@ test("editing a live thread reply uses and focuses the thread composer", async ( const reply = `Live reply edit ${Date.now()}`; await page.goto("/"); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const mainInput = page .getByTestId("channel-composer-overlay") .getByTestId("message-input"); @@ -3707,10 +3708,8 @@ test("editing a live thread reply uses and focuses the thread composer", async ( .getByTestId("message-timeline") .getByTestId("message-row") .last(); - await timelineRoot.hover(); - await timelineRoot - .getByRole("button", { name: "Reply" }) - .click({ force: true }); + await expect(timelineRoot).toContainText(root); + await replyFromKeyboard(timelineRoot); const threadPanel = page.getByTestId("message-thread-panel"); const threadInput = threadPanel.getByTestId("message-input"); @@ -3734,7 +3733,7 @@ test("editing a thread root in single-panel view returns to the main composer", const root = `Narrow root edit ${Date.now()}`; await page.goto("/"); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const input = page.getByTestId("message-input"); await input.fill(root); await input.press("Enter"); @@ -3742,10 +3741,8 @@ test("editing a thread root in single-panel view returns to the main composer", .getByTestId("message-timeline") .getByTestId("message-row") .last(); - await timelineRoot.hover(); - await timelineRoot - .getByRole("button", { name: "Reply" }) - .click({ force: true }); + await expect(timelineRoot).toContainText(root); + await replyFromKeyboard(timelineRoot); const threadPanel = page.getByTestId("message-thread-panel"); const threadRoot = threadPanel.getByTestId("message-row").first(); @@ -3770,7 +3767,7 @@ test("editing a thread root in focus mode dismisses the drawer before focusing t const root = `Focus root edit ${Date.now()}`; await page.goto("/"); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const mainInput = page .getByTestId("channel-composer-overlay") .getByTestId("message-input"); @@ -3780,10 +3777,8 @@ test("editing a thread root in focus mode dismisses the drawer before focusing t .getByTestId("message-timeline") .getByTestId("message-row") .last(); - await timelineRoot.hover(); - await timelineRoot - .getByRole("button", { name: "Reply" }) - .click({ force: true }); + await expect(timelineRoot).toContainText(root); + await replyFromKeyboard(timelineRoot); const drawer = page.getByTestId("focus-thread-drawer"); const threadRoot = drawer.getByTestId("message-row").first(); @@ -3807,7 +3802,7 @@ test("focus mode preserves an active reply edit, then Escape makes root editing const unsaved = `${reply} unsaved`; await page.goto("/"); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const mainInput = page .getByTestId("channel-composer-overlay") .getByTestId("message-input"); @@ -3817,10 +3812,8 @@ test("focus mode preserves an active reply edit, then Escape makes root editing .getByTestId("message-timeline") .getByTestId("message-row") .last(); - await timelineRoot.hover(); - await timelineRoot - .getByRole("button", { name: "Reply" }) - .click({ force: true }); + await expect(timelineRoot).toContainText(root); + await replyFromKeyboard(timelineRoot); const drawer = page.getByTestId("focus-thread-drawer"); const threadInput = drawer.getByTestId("message-input"); @@ -3840,7 +3833,8 @@ test("focus mode preserves an active reply edit, then Escape makes root editing .getByTestId(`edit-message-${threadReplyId}`) .click(); await expect(page.locator('[role="menu"]:visible')).toHaveCount(0); - await threadInput.fill(unsaved); + await expectReplyEditReady(drawer, reply); + await replaceComposerText(threadInput, unsaved); const threadRoot = drawer .getByTestId("message-thread-head") @@ -3882,8 +3876,8 @@ test("focus mode preserves an active reply edit, then Escape makes root editing // Focus-mode Escape reaches the composer before the drawer close handler. await threadInput.click(); await page.keyboard.press("ArrowUp"); - await expect(drawer.getByTestId("edit-target")).toBeVisible(); - await threadInput.fill(unsaved); + await expectReplyEditReady(drawer, reply); + await replaceComposerText(threadInput, unsaved); await page.keyboard.press("Escape"); await expect(drawer.getByTestId("edit-target")).toHaveCount(0); await expect(drawer).toBeVisible(); @@ -3904,7 +3898,7 @@ test("ArrowUp routes a narrow thread root without consuming into a hidden compos await page.setViewportSize({ width: 860, height: 720 }); const root = `Narrow ArrowUp root ${Date.now()}`; await page.goto("/"); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const input = page.getByTestId("message-input"); await input.fill(root); await input.press("Enter"); @@ -3912,10 +3906,8 @@ test("ArrowUp routes a narrow thread root without consuming into a hidden compos .getByTestId("message-timeline") .getByTestId("message-row") .last(); - await timelineRoot.hover(); - await timelineRoot - .getByRole("button", { name: "Reply" }) - .click({ force: true }); + await expect(timelineRoot).toContainText(root); + await replyFromKeyboard(timelineRoot); const threadInput = page .getByTestId("message-thread-panel") .getByTestId("message-input"); @@ -3963,7 +3955,7 @@ test("closing a thread while editing a reply preserves the typed edit", async ({ await threadReply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); await expectReplyEditReady(threadPanel, reply); - await threadInput.fill(edited); + await replaceComposerText(threadInput, edited); await threadPanel.getByTestId("auxiliary-panel-close").click(); @@ -3982,7 +3974,7 @@ test("main ArrowUp ignores closed-thread replies and edits the visible timeline const reply = `Main ArrowUp hidden reply ${Date.now()}`; await page.goto("/"); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const mainInput = page .getByTestId("channel-composer-overlay") .getByTestId("message-input"); @@ -3992,10 +3984,8 @@ test("main ArrowUp ignores closed-thread replies and edits the visible timeline .getByTestId("message-timeline") .getByTestId("message-row") .last(); - await timelineRoot.hover(); - await timelineRoot - .getByRole("button", { name: "Reply" }) - .click({ force: true }); + await expect(timelineRoot).toContainText(root); + await replyFromKeyboard(timelineRoot); const threadPanel = page.getByTestId("message-thread-panel"); const threadInput = threadPanel.getByTestId("message-input"); @@ -4012,10 +4002,7 @@ test("main ArrowUp ignores closed-thread replies and edits the visible timeline // No hidden reply edit may block reopening its thread. await mainInput.press("Escape"); - await timelineRoot.hover(); - await timelineRoot - .getByRole("button", { name: "Reply" }) - .click({ force: true }); + await replyFromKeyboard(timelineRoot); await expect(threadPanel).toBeVisible(); }); @@ -4053,7 +4040,7 @@ test("main ArrowUp refuses to replace a dirty thread edit", async ({ await threadReply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); await expectReplyEditReady(threadPanel, reply); - await threadInput.fill(unsaved); + await replaceComposerText(threadInput, unsaved); await mainInput.click(); await page.keyboard.press("ArrowUp"); @@ -4127,7 +4114,7 @@ test("a refused message deep link retries after the thread edit is canceled", as const destinationRoot = `Deep link retry destination ${Date.now()}`; await page.goto("/"); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const mainInput = page .getByTestId("channel-composer-overlay") .getByTestId("message-input"); @@ -4158,8 +4145,7 @@ test("a refused message deep link retries after the thread edit is canceled", as .getByTestId("message-row") .filter({ hasText: sourceRoot }) .last(); - await source.hover(); - await source.getByRole("button", { name: "Reply" }).click(); + await replyFromKeyboard(source); const threadPanel = page.getByTestId("message-thread-panel"); const threadInput = threadPanel.getByTestId("message-input"); await threadInput.fill(reply); @@ -4172,7 +4158,7 @@ test("a refused message deep link retries after the thread edit is canceled", as await threadReply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); await expectReplyEditReady(threadPanel, reply); - await threadInput.fill(`${reply} unsaved`); + await replaceComposerText(threadInput, `${reply} unsaved`); const threadUrl = page.url(); expect(threadUrl).toContain( @@ -4242,11 +4228,10 @@ test("a refused sent-from-thread link preserves the edit and retries after cance { destinationRoot, sharedMessage, sourceReply, sourceRoot }, ); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const timeline = page.getByTestId("message-timeline"); const source = timeline.locator(`[data-message-id="${sourceRootId}"]`); - await source.hover(); - await source.getByRole("button", { name: "Reply" }).click(); + await replyFromKeyboard(source); const threadPanel = page.getByTestId("message-thread-panel"); const threadInput = threadPanel.getByTestId("message-input"); const reply = threadPanel @@ -4257,7 +4242,7 @@ test("a refused sent-from-thread link preserves the edit and retries after cance await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); await expectReplyEditReady(threadPanel, sourceReply); - await threadInput.fill(dirtyReply); + await replaceComposerText(threadInput, dirtyReply); const threadUrl = page.url(); expect(threadUrl).toContain(`thread=${sourceRootId}`); @@ -4317,11 +4302,10 @@ test("a refused search result preserves the edit and retries after cancel", asyn { destinationRoot, sourceReply, sourceRoot }, ); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const timeline = page.getByTestId("message-timeline"); const source = timeline.locator(`[data-message-id="${sourceRootId}"]`); - await source.hover(); - await source.getByRole("button", { name: "Reply" }).click(); + await replyFromKeyboard(source); const threadPanel = page.getByTestId("message-thread-panel"); const threadInput = threadPanel.getByTestId("message-input"); const reply = threadPanel @@ -4332,7 +4316,7 @@ test("a refused search result preserves the edit and retries after cancel", asyn await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); await expectReplyEditReady(threadPanel, sourceReply); - await threadInput.fill(dirtyReply); + await replaceComposerText(threadInput, dirtyReply); const threadUrl = page.url(); expect(threadUrl).toContain(`thread=${sourceRootId}`); @@ -4391,12 +4375,11 @@ test("a refused forum search result preserves the edit and retries after cancel" { sourceReply, sourceRoot }, ); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const source = page .getByTestId("message-timeline") .locator(`[data-message-id="${sourceRootId}"]`); - await source.hover(); - await source.getByRole("button", { name: "Reply" }).click(); + await replyFromKeyboard(source); const threadPanel = page.getByTestId("message-thread-panel"); const threadInput = threadPanel.getByTestId("message-input"); const reply = threadPanel @@ -4407,7 +4390,7 @@ test("a refused forum search result preserves the edit and retries after cancel" await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); await expectReplyEditReady(threadPanel, sourceReply); - await threadInput.fill(dirtyReply); + await replaceComposerText(threadInput, dirtyReply); const threadUrl = page.url(); const editTarget = threadPanel.getByTestId("edit-target"); @@ -4478,11 +4461,10 @@ for (const targetKind of ["reply", "root"] as const) { { sourceReply, sourceRoot, targetKind }, ); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const timeline = page.getByTestId("message-timeline"); const source = timeline.locator(`[data-message-id="${sourceRootId}"]`); - await source.hover(); - await source.getByRole("button", { name: "Reply" }).click(); + await replyFromKeyboard(source); const threadPanel = page.getByTestId("message-thread-panel"); const threadInput = threadPanel.getByTestId("message-input"); @@ -4491,7 +4473,7 @@ for (const targetKind of ["reply", "root"] as const) { await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); await expectReplyEditReady(threadPanel, sourceReply); - await threadInput.fill(dirtyReply); + await replaceComposerText(threadInput, dirtyReply); const targetLink = timeline .getByTestId("message-row") @@ -4586,7 +4568,7 @@ test("a refused channel switch preserves the reply edit and retries after cancel await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); await expectReplyEditReady(threadPanel, sourceReply); - await threadInput.fill(dirtyReply); + await replaceComposerText(threadInput, dirtyReply); const navigationBefore = await page.evaluate(() => ({ historyLength: history.length, @@ -4657,12 +4639,11 @@ for (const backInput of ["button", "keyboard"] as const) { ); await page.getByTestId("channel-random").click(); - await page.getByTestId("channel-general").click(); + await openGeneral(page); const source = page .getByTestId("message-timeline") .locator(`[data-message-id="${sourceRootId}"]`); - await source.hover(); - await source.getByRole("button", { name: "Reply" }).click(); + await replyFromKeyboard(source); const threadPanel = page.getByTestId("message-thread-panel"); const threadInput = threadPanel.getByTestId("message-input"); @@ -4671,7 +4652,7 @@ for (const backInput of ["button", "keyboard"] as const) { await reply.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); await expectReplyEditReady(threadPanel, sourceReply); - await threadInput.fill(dirtyReply); + await replaceComposerText(threadInput, 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 4df65c7cbf3..63f2af7012c 100644 --- a/desktop/tests/e2e/remote-owned-mentions.spec.ts +++ b/desktop/tests/e2e/remote-owned-mentions.spec.ts @@ -5,6 +5,7 @@ import { openNewMessagePage, TEST_IDENTITIES, } from "../helpers/bridge"; +import { clearComposer, replaceComposerText } from "../helpers/composer"; const OWNER = "deadbeef".repeat(8); const REMOTE = "ed".repeat(32); @@ -520,8 +521,9 @@ for (const incoming of ["unrelated thread B draft", "@RemoteScout hello"]) { await page.getByRole("button", { name: "Invite", exact: true }).click(); await waitForInviteGate(page); await expect(input).toHaveText(""); - await input.fill("new authored text"); - await input.fill(""); + // Closing the dialog hands focus back to the editor a frame later. + await replaceComposerText(input, "new authored text"); + await clearComposer(input); const sourceRecord = () => page.evaluate(([root, otherRoot]) => { const key = Object.keys(localStorage).find((key) => diff --git a/desktop/tests/e2e/thread-follow.spec.ts b/desktop/tests/e2e/thread-follow.spec.ts index eb6c7f26c5c..7b05e1325c3 100644 --- a/desktop/tests/e2e/thread-follow.spec.ts +++ b/desktop/tests/e2e/thread-follow.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { openGeneral, pressRowAction } from "../helpers/channelHeadReady"; const CHANNEL = "general"; const SELF_PUBKEY = "deadbeef".repeat(8); @@ -81,8 +82,7 @@ async function openMessageMenu( `[data-testid="message-row"][data-message-id="${messageId}"]`, ); await row.evaluate((element) => element.scrollIntoView({ block: "center" })); - await row.hover(); - await row.getByTestId(`more-actions-${messageId}`).click(); + await pressRowAction(row.getByTestId(`more-actions-${messageId}`)); await expect(page.getByRole("menu")).toBeVisible(); } @@ -352,7 +352,7 @@ test("a mention-only root offers follow and explicit follow enables ordinary rep }) => { await installMockBridge(page); await page.goto("/"); - await page.getByTestId(`channel-${CHANNEL}`).click(); + await openGeneral(page); await waitForMockLiveSubscription(page, CHANNEL); const root = await emitMessage(page, { diff --git a/desktop/tests/e2e/thread-head-stale-edit.spec.ts b/desktop/tests/e2e/thread-head-stale-edit.spec.ts index c72ebc78f79..e88c09ce20e 100644 --- a/desktop/tests/e2e/thread-head-stale-edit.spec.ts +++ b/desktop/tests/e2e/thread-head-stale-edit.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; +import { openGeneral, pressRowAction } from "../helpers/channelHeadReady"; // Regression coverage for the reported bug: the top-level timeline shows the // EDITED message body, but opening its thread panel shows the STALE, un-edited @@ -59,7 +60,9 @@ test("thread head reflects the channel-window edit even before thread aux loads" // provably cannot land until we release it after asserting the head. await installMockBridge(page, { deferThreadReplies: true }); await page.goto("/"); - await page.getByTestId(`channel-${CHANNEL}`).click(); + // Let the head refresh land and the timeline pin to the bottom before + // touching a row. + await openGeneral(page); await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL); await waitForMockLiveSubscription(page, CHANNEL); @@ -121,8 +124,7 @@ test("thread head reflects the channel-window edit even before thread aux loads" ); // 3. Open the thread via the reply action (the flow in the bug report). - const replyButton = page.getByTestId(`reply-message-${rootId}`); - await replyButton.click({ force: true }); + await pressRowAction(page.getByTestId(`reply-message-${rootId}`)); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); diff --git a/desktop/tests/helpers/channelHeadReady.ts b/desktop/tests/helpers/channelHeadReady.ts index d24edcb8b49..1c96590228c 100644 --- a/desktop/tests/helpers/channelHeadReady.ts +++ b/desktop/tests/helpers/channelHeadReady.ts @@ -1,4 +1,7 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; + +/** The seeded #general channel's id in the mock bridge. */ +export const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; // These default mock fixtures have immediate EOSE and no injected reconnect. // Steady-state pagination begins after live admission and its head refresh, @@ -73,3 +76,17 @@ export async function waitForMockChannelHeadReady( }) .toBeLessThanOrEqual(2); } + +// Opening #general refreshes the head after the live subscription lands, and +// the timeline re-pins while it does. Wait for that before touching a row. +export async function openGeneral(page: Page) { + await page.getByTestId("channel-general").click(); + await waitForMockChannelHeadReady(page, "general", GENERAL_CHANNEL_ID); +} + +// Row actions are hover-only, and a moving row slips out from under the +// pointer. The action bar is also shown on focus-within, so use the keyboard. +export async function pressRowAction(button: Locator) { + await button.focus(); + await button.press("Enter"); +} diff --git a/desktop/tests/helpers/composer.ts b/desktop/tests/helpers/composer.ts new file mode 100644 index 00000000000..8eef3bedd25 --- /dev/null +++ b/desktop/tests/helpers/composer.ts @@ -0,0 +1,40 @@ +import { expect, type Locator } from "@playwright/test"; + +/** + * Replaces a composer's draft with `text`, for a composer the app may still be + * focusing. + * + * Entering edit mode (and restoring focus after a mention dialog) focuses the + * end of the draft in deferred steps: one animation frame sets the selection + * to the end, a nested frame runs ProseMirror's `view.focus()`, and that focus + * schedules a DOM-selection resync. `fill()` selects the contents and then + * inserts text in separate protocol steps; a collapse that lands between them + * appends the new text instead of replacing the draft. Clearing first makes + * that collapse harmless: an empty document has a single caret position. + */ +export async function replaceComposerText(input: Locator, text: string) { + await clearComposer(input); + await input.fill(text); +} + +/** + * Clears a composer through the editor's own keymap, retrying until it is + * empty. Use it on a composer the app focuses itself (edit mode, a closed + * dialog); it waits for that focus first. + * + * `fill("")` selects the contents through the DOM and then presses Delete. + * ProseMirror only learns about a DOM selection when `selectionchange` fires; + * if the key arrives first, it still sees a caret at the end of the text and + * blocks the Delete, leaving the draft intact. Select-all as a key press goes + * through the keymap and updates the editor's selection synchronously. A + * queued focus-to-end can still collapse it before the Delete, which then + * deletes nothing, so the clear repeats until the document is empty. + */ +export async function clearComposer(input: Locator) { + await expect(input).toBeFocused(); + await expect(async () => { + await input.press("ControlOrMeta+a"); + await input.press("Delete"); + await expect(input).toBeEmpty({ timeout: 250 }); + }).toPass({ timeout: 5_000 }); +}