From f977e7e19b253ed01c108398af2eb9d158382429 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 16:30:14 +1100 Subject: [PATCH] test(desktop): settle composer edits and press hover-only row actions by keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-side fixes for the desktop E2E composer-focus (§1) and hover-only action bar (§3/§8/§18) flakes from the 21-28 Oct 2026 CI audit. Test code only. Line numbers are as on main and in the cited CI runs. Pre-check: origin/main (eda26b942) and the open or merged PRs that touch these specs or helpers (#8559, #8622, #8613) had no coverage for these clusters. Draft #8661 edits empty-edit-delete.spec.ts's submitEmptyEdit and beforeEach, the same lines as below. Before/after counts come from running main's copy and the changed copy of each spec side by side in one invocation (pnpm build:e2e, --retries=0 --trace=off, Playwright outside the sandbox). The forcing hooks (CDP CPU throttle, delayed RAF/selectionchange/timers, a gap inside fill()) lived in a local bridge.ts patch and are not committed. New helpers - tests/helpers/composer.ts: clearComposer waits for the app's focus, then repeats select-all + Delete through the editor keymap until the editor is empty (250 ms per try, 5 s overall); a late collapse that makes Delete a no-op just costs another try. replaceComposerText clears, then fills; on an empty document a late collapse to the end is a no-op. - tests/helpers/channelHeadReady.ts: GENERAL_CHANNEL_ID, openGeneral (click #general, then waitForMockChannelHeadReady) and pressRowAction(button) (focus + Enter). The action bar is also shown on focus-within, and more-actions is a Radix trigger that opens on Enter. §1 composer focus races Tests and runs: inbox-edit.spec.ts:525 39641311877, 39821769576, 40333705511, 40348069929, 40388246283 inbox-edit.spec.ts:560 39538411386, 39821769576 mention-recipients.spec.ts:591 (both variants) 39799090750, 40374286094, 39641311877 mention-recipients.spec.ts:691 (both variants) 40348069929, 40556019361, 39601763602 messaging.spec.ts:3799 39827234118, 40445481983 messaging.spec.ts:4022 40389392495 messaging.spec.ts:4289 39822883395, 40557808800, 40566841699 messaging.spec.ts:4368 40574058918 remote-owned-mentions.spec.ts:463 (both variants) 39618692657, 40374286094, 40382726903, 40389392495, 40582387515 Cause A (append): entering edit mode, or closing the non-member mention dialog, focuses the end of the draft in deferred steps: a frame sets the selection, a nested frame runs ProseMirror's view.focus(), and that schedules a 20 ms DOM-selection resync. fill() selects the contents and inserts text in separate protocol steps; a step that lands between them collapses the selection and the text is appended ("Search guard reply N Search guard reply N unsaved ..."). Cause B (clear does nothing): fill("") selects through the DOM and then presses Delete. If the key arrives before ProseMirror handles `selectionchange`, the editor still sees a caret at the end of the textblock and blocks the Delete, so the draft survives. Change: clearComposer / replaceComposerText in inbox-edit's submitEmptyEdit, the eleven fills after messaging's expectReplyEditReady, the mention-recipients repairs, remote-owned-mentions after the Invite dialog, and empty-edit-delete (submitEmptyEdit and the non-empty edit). Counts, before -> after, measured with the helper's first version (two frames plus the 20 ms resync before fill()): 4x throttle, 10 repeats: inbox :525 7/10, :560 7/10; mention :591 7/10 and 9/10, :691 9/10 and 8/10; messaging :3799 9/10, :4022 10/10, :4289 6/10, :4368 5/10; remote-owned :463 7/10 and 8/10. After 130/130. selectionchange delayed 50 ms, 5 repeats: inbox :525 and :560 0/5 each, remote-owned both variants 0/5 each -> 5/5 each. 20 ms resync stretched to 200 ms, selectionchange +400 ms, 300 ms gap inside fill(), 5 repeats: mention-recipients all four variants 0/5, messaging :3799 :4022 :4289 :4368 0/5 each, inbox 3/5 and 4/5. After 65/65. every RAF deferred 150 ms, 400 ms gap inside fill(): mention-recipients 0/20 -> 20/20, messaging :3799 0/5 -> 5/5. The final, state-based helper replaced the fixed 20 ms wait because it only ordered after the app's focus-to-end if that RAF chain had already started. It ran unforced only (157/157 over these specs); clearComposer's retry was not checked under a forced late collapse. §3/§8/§18 hover-only Reply and more-actions on a re-pinning timeline Tests and runs: messaging.spec.ts:3531 40583830861, 40595483966 messaging.spec.ts:3647 40566841699, 40597484657 messaging.spec.ts:4122 39641311877, 40368315739, 40368363357 messaging.spec.ts:4208 39525092486, 39796569342, 40367495044 messaging.spec.ts:4289, :4368 (runs above) messaging.spec.ts:4450 root 39796569342, 40348069929; reply 40373404267 messaging.spec.ts:4633 keyboard 39530486096, 40348069929, 40368315739, 40582387515; button 39530486096, 40368363357 thread-follow.spec.ts:350 39529963092, 40348069929, 40583830861 (all 3), 40595483966, PR run 39541263507 (all 3) thread-head-stale-edit.spec.ts:55 39525092486, 39618692657, 39796569342, 39822883395, 40011714848, 40281448810, 40368363357, 40389392495, 40445481983, 40574058918, PR run 40031299575 (all 3) file-attachment.spec.ts:165 (one or two CI failures) empty-edit-delete.spec.ts (openMoreActionsMenu) Cause: the message action bar is pointer-events-none until its row is hovered. Opening #general refreshes the head after the live subscription lands and the timeline re-pins, so the row moves out from under the pointer and the Reply / more-actions click times out or the composer overlay intercepts it. `force: true` (thread-head-stale-edit, :3647 and six more messaging tests) only hid this. Change: openGeneral after opening #general, then pressRowAction on the button. Six more messaging tests that opened #general with a bare click and reached Reply with hover + click({ force: true }) get the same change (the CI-flagged :3799 among them); each first Reply follows a send and targets message-row .last(), so it now waits for that row to contain the sent root first. "closing a thread while editing a reply" hovers and clicks without force after waitForAnimations and is unchanged. Counts (4x throttle, 10 repeats), before -> after: :3531 10/10, :3647 10/10, :4122 9/10 (a §1 failure), :4208 7/10 (3 Reply-click timeouts), :4289 8/10 (2), :4368 8/10 (1 timeout, 1 §1), :4450 reply 9/10, root 10/10, :4633 keyboard 7/10 (3 timeouts), button 10/10, thread-follow :350 9/10 (more-actions intercepted by the composer overlay), thread-head-stale-edit :55 10/10. After 120/120. file-attachment :165 (row kept moving): before 0/3 (row.hover timeout), after 3/3. Not force-reproduced: :3531, :3647, :4122, :4450 root, :4633 button, thread-head-stale-edit :55 and the six force-click messaging tests. They get the same change on their CI signatures and the reproduced siblings. messaging.spec.ts:4549 is unchanged as a control. §9 file-attachment.spec.ts:419 "upload progress floats above the dock and lifts Jump to latest" (in this PR because the same file imports openGeneral and pressRowAction) Runs: 10 of 50 main jobs, including 40583830861, 40597484657 and 40608383847. Cause: the test left the bottom with a programmatic scrollTop, which keeps useVirtualizedBottomSettle's bottom intent armed. The upload card resizes the content, the timeline re-pins to the bottom, and the pill unmounts under boundingBox(). The fixed 250 ms wait then also read the card mid-slide (y: 28 -> 0 over 0.22 s). Change: scrollTimelineUp() leaves the bottom with a real wheel, and one expect.poll checks all three layout relations instead of a fixed wait. Counts (unforced, 5 repeats): before 4/5 (the failure is CI's boundingBox timeout at :457), after 5/5. Dropped: - mention-recipients.spec.ts:933 (40608383847) fails at :995 on `flavors.handled`, which is unrelated to composer focus. - persistent-agent-audience.spec.ts:828 (39794396891, 40556742929; "@Mor@Morg" at :881) did not reproduce in 79 runs of main under seven forcings, so it is left unchanged. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- desktop/tests/e2e/empty-edit-delete.spec.ts | 17 +-- desktop/tests/e2e/file-attachment.spec.ts | 67 +++++---- desktop/tests/e2e/inbox-edit.spec.ts | 4 +- desktop/tests/e2e/mention-recipients.spec.ts | 5 +- desktop/tests/e2e/messaging.spec.ts | 127 ++++++++---------- .../tests/e2e/remote-owned-mentions.spec.ts | 6 +- desktop/tests/e2e/thread-follow.spec.ts | 6 +- .../tests/e2e/thread-head-stale-edit.spec.ts | 8 +- desktop/tests/helpers/channelHeadReady.ts | 19 ++- desktop/tests/helpers/composer.ts | 40 ++++++ 10 files changed, 174 insertions(+), 125 deletions(-) create mode 100644 desktop/tests/helpers/composer.ts 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 }); +}