From 0fcf61948a352751fe17184999086a85ed3023fc Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 30 Oct 2026 16:37:33 +1100 Subject: [PATCH 1/2] test(desktop): settle five E2E flake classes before measuring or acting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Units 6-9 of the 23-30 Oct desktop Playwright flake audit. All are test-only. origin/main (1eb67bb52, now including #8680) hasn't touched these specs since fae3f0346. No open PR touches the tests changed here: #6134 (conflicting since 10-19) only swaps page.goto calls in three of these files, #8187's hunk starts just after workflow-local-controls:151, #5778 (draft, conflicting) creates projects-v3-screenshots from an older base, and #8622, #7452, #7812, #8244 and #3342 edit other tests in the same files. Unit 6: measuring before an animation finishes onboarding.spec.ts:1487, "hosted community address line stays within the card for a long name" Run: #8715 (Integration shard 1), which doesn't touch onboarding. Signature: the centring check at :1545 expects <= 2 and receives 5.13. Cause: "Connect and continue" ends in showPage("owned") (WelcomeSetup.tsx:323), which mounts an OnboardingSlideTransition. Its content slides in from translateX(48px) over 650 ms (components.css:521, :676). fill() doesn't wait for stability, and the surface, input and suffix boxes come from three boundingBox round trips, so they can be read at different points of the slide. Change: waitForAnimations(page) after the fill, before measuring. onboarding.spec.ts:2289, "connected first-community profile keeps navigation inside the card and balances the avatar editor" Runs: main 40746677244 and 40925172215, and #8666 (Integration shard 1). Signature: the spacing check at :2485 expects <= 4 and receives 4.255. Cause: the avatar dialog opens with MODAL_CONTENT_MOTION_CLASS, a 200 ms zoom-in-95 (modalMotion.ts:5), and has a 250 ms height transition. dialogBox is read in one round trip and the preview and editor boxes in a later one, so a scale change between them skews the gap comparison. Change: waitForAnimations(page) once the dialog is visible. projects-v3-screenshots.spec.ts:280, "projects v3 workspace screenshot states", failing in the expectInsetSection call at :893 Runs: main 39643531998, 40759209995 and 40969909906 (Smoke shard 8), each passing on retry. Signature: the header's right inset at :326 expects 16 and receives 8. Four more runs fail the menu edge check at :328 with fractional values (expected 984, received 975.6875): 39622053330, 39641311877, 40281448810 and 40856574572. Cause: switching the context rail back from chat to repository narrows it from the thread panel's 380 px to the repository's 280 px (useProjectPanelWidths.ts:5, :17), animated by transition-[width] duration-200 (ProjectContextRail.tsx:31). The workspace widens with it, and expectInsetSection reads the workspace, header and menu boxes in three separate round trips. The fractional widths are mid-transition values. Change: waitForAnimations(page) before that call, the only one that follows a rail width change. Unit 7: fill() racing a caret move the app makes a frame later mention-recipients.spec.ts:376, "editing to a longer typed member drops the original shorter reference", and :97, "ambiguous added mention blocks editing before clearing the draft" Runs: main 40969965981 (Smoke shard 5, a run with every earlier fix) for :376. 40969858676 for :97, before the fixes. Signature: the edit_message poll at :428 receives null. The error context shows the edited message as "@Scout hello @Scout Jones hello": fill() appended. Cause: entering edit mode sets the content, then focuses its end in a requestAnimationFrame (MessageComposer.tsx:413). toHaveText passes as soon as the content lands, before that frame. If the frame lands between fill()'s select-all and its typing, the selection collapses and the text is appended. #8682's replaceComposerText handles this, but these two call sites (:115 and :403) were missed. Change: replaceComposerText at both. workflow-local-controls.spec.ts:151, "inserts template variables with keyboard control and restores the caret" Runs: main 40926393897 and 40957616146 (before the fixes), and #8239, which changes no desktop files. Signature: :194 expects "Keep {{auth" and receives "Hello {{trigger.text}}Keep {{auth". Cause: selectVariable restores the caret in a requestAnimationFrame (WorkflowTemplateTextarea.tsx:66). Setting the controlled value has already put the caret at the end, so the selectionStart poll passes before that frame runs. The frame then collapses fill()'s select-all. Change: after the poll, wait two animation frames, then fill. This is a frame barrier, not a timer. The app queued its frame before the evaluate, so it runs first. Unit 8: clicks that hit the wrong element profile.spec.ts:1128, "renders agent profile ingress subviews from the Playwright mock bridge", failing at :1543 Run: #8523, which changes no desktop files. Its base (ef4130da4) already includes #8681. Signature: archiveAgentRow.click() times out because three stacked Sonner success toasts (data-expanded="true") intercept the pointer. Cause: the test's Stop and Start steps raise success toasts (useAgentLifecycleActions.ts:51, :61). Sonner stacks them bottom right, over the profile panel's action rows, and pauses their dismissal while the pointer is over them. #8681 fixed this test's motion window, and fixed the toast problem only in agent-availability:602. Change: press the five action rows (:1528-:1547) through a local pressActionRow helper: toBeEnabled, focus, Enter, as agent-availability.spec.ts:744-745 does. toBeEnabled keeps the wait click() gave, since these rows are disabled while a delete or archive is pending. config-bridge-screenshots.spec.ts:314, "07 — profile side panel — Configuration section" Runs: main 40967147375 (before the fixes), and #8235, which changes no desktop files. Signature: "Model" isn't visible within 10 s at :352. The error context shows nadia's panel ("Not managed on this device"), not Charlie's. Cause: the spec clicked .last() of the rows with a [data-testid^="message-avatar-"] descendant. That matches only UserAvatar's -image and -fallback children. Radix renders the image only once it loads, and each fallback only after its own 200 ms delayMs timer (UserAvatar.tsx:67, :119). Neither avatar in #agents has a URL, so the matching set fills in row by row as the timers fire. Resolved in between, .last() is nadia's row (seeded at -85 s), just above Charlie's "reporting in." row (-80 s). The note blamed a misplaced click; the code points at locator resolution instead. Change: pick Charlie's row by its text, as profile.spec.ts does for Charlie's other #agents message. Then check the panel's name row reads charlie before opening Runtime, so a wrong panel fails there and says so. Unit 9: the createWorkflow copies (preventive) navigation.spec.ts:153, "direct workflow detail links close back to workflows" Runs: 7 failures on main, the last at 29 Oct 16:12Z, none after #8681. Signature: the Edit workflow dialog has no "Trigger: Message Posted" at :176. Runs: 14 first-attempt failures in Smoke shard 7, all passing on retry; on main 39827234118, 40718018673, 40789486699, 40802051064, 40840949170, 40891643205 and 40909291143. Cause: the same race #8681 fixed in workflows.spec's createWorkflow. Picking a channel opens the trigger pane (WorkflowDialog.tsx:702), and for Message Posted the conditions editor expands "Message text" by default (WorkflowTriggerConditions.tsx:157), rendering an input with that label. After "Send Message", AnimatePresence mode="wait" (WorkflowFormBuilder.tsx:907) keeps the trigger body mounted through its exit and holds the step body back. An unscoped getByLabel("Message text") can fill the trigger's filter. The step then has no text, which yamlToFormState rejects (workflowFormTypes.ts:348), so the Edit dialog opens in YAML mode (WorkflowDialog.tsx:88). That is the signature the earlier audit recorded for navigation:153. Change: wait for the step inspector's "Action" header and "Step details" control, then fill inside the inspector, as workflows.spec does. workflows.spec.ts:716 ("captures the built editor at desktop and narrow widths", fill at :726) and :820 ("pane routes use stable IDs and Form/YAML changes stay synchronized", fill at :831) pick a channel and then add a Send Message step the same way, with no failures in the window. So the wait moves into one addSendMessageStep helper in tests/helpers/workflows.ts, rather than four copies. Both specs' createWorkflow and those two tests call it. A change to the inspector's structure then needs one fix. Left out: workflow-reaction-picker.spec.ts:203-204. The note listed it, but its trigger is Reaction Added, whose condition fields are "Reaction emoji", "Author" and "Message" (workflowConditionExpression.ts:40). No trigger control there is labelled "Message text", so the unscoped fill can only reach the step's textarea. Verification Not run locally: Playwright bogs this machine down, and the Flake Check lane will repeat the touched specs. pnpm typecheck and pnpm check pass. biome's 3 warnings are in untouched files. An ad hoc tsc ran over the eight touched specs, using the desktop tsconfig with only those files, and over HEAD's copies of them. Both gave the same errors: onboarding 16, navigation 7, profile 7, workflow-local-controls 7, projects-v3-screenshots 2, and none in mention-recipients, config-bridge-screenshots or workflows. The messages are identical once line numbers are stripped, and all are pre-existing (no Node types, import.meta.env, MockBridgeOptions drift and the like). Once the helper moved to tests/helpers/workflows.ts, a rerun over navigation, workflows and the helper gave navigation's same 7 and none in the other two. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../e2e/config-bridge-screenshots.spec.ts | 10 +++++--- desktop/tests/e2e/mention-recipients.spec.ts | 4 +-- desktop/tests/e2e/navigation.spec.ts | 5 ++-- desktop/tests/e2e/onboarding.spec.ts | 7 ++++++ desktop/tests/e2e/profile.spec.ts | 20 ++++++++++----- .../tests/e2e/projects-v3-screenshots.spec.ts | 3 +++ .../tests/e2e/workflow-local-controls.spec.ts | 10 ++++++++ desktop/tests/e2e/workflows.spec.ts | 25 +++---------------- desktop/tests/helpers/workflows.ts | 23 +++++++++++++++++ 9 files changed, 72 insertions(+), 35 deletions(-) create mode 100644 desktop/tests/helpers/workflows.ts diff --git a/desktop/tests/e2e/config-bridge-screenshots.spec.ts b/desktop/tests/e2e/config-bridge-screenshots.spec.ts index 3e800e72b09..2792c139756 100644 --- a/desktop/tests/e2e/config-bridge-screenshots.spec.ts +++ b/desktop/tests/e2e/config-bridge-screenshots.spec.ts @@ -331,17 +331,21 @@ test.describe("config bridge screenshots", () => { await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); - // Click an agent avatar in a full message row to open the profile side panel. + // Click Charlie's avatar to open the profile side panel. Pick the row by + // its text: each avatar fallback mounts on its own 200 ms timer after its + // row, so "the last row with an avatar" can still be nadia's. await page .getByTestId("message-row") - .filter({ has: page.locator('[data-testid^="message-avatar-"]') }) - .last() + .filter({ hasText: "Charlie reporting in." }) .getByRole("button") .first() .click(); const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible({ timeout: 10_000 }); + await expect(panel.getByTestId("user-profile-name-row")).toContainText( + /charlie/i, + ); // Editable configuration lives inside the Runtime tab. await panel.getByRole("tab", { name: "Runtime" }).click(); diff --git a/desktop/tests/e2e/mention-recipients.spec.ts b/desktop/tests/e2e/mention-recipients.spec.ts index fc0ca29651b..716c9ca4654 100644 --- a/desktop/tests/e2e/mention-recipients.spec.ts +++ b/desktop/tests/e2e/mention-recipients.spec.ts @@ -112,7 +112,7 @@ test("ambiguous added mention blocks editing before clearing the draft", async ( await page.getByRole("menuitem", { name: "Edit message" }).click(); await expect(page.getByTestId("edit-target")).toBeVisible(); await expect(input).toHaveText("original message for ambiguity edit"); - await input.fill("edited @Scout hello"); + await replaceComposerText(input, "edited @Scout hello"); await page.getByTestId("send-message").click(); await expect(page.getByText(AMBIGUOUS, { exact: false })).toBeVisible(); await expect(input).toHaveText("edited @Scout hello"); @@ -400,7 +400,7 @@ test("editing to a longer typed member drops the original shorter reference", as await row.getByRole("button", { name: "More actions" }).click(); await page.getByRole("menuitem", { name: "Edit message" }).click(); await expect(input).toHaveText("@Scout hello"); - await input.fill("@Scout Jones hello"); + await replaceComposerText(input, "@Scout Jones hello"); await page.getByTestId("send-message").click(); await expect .poll(() => diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 45f32fcd9b6..59a8338071e 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; +import { addSendMessageStep } from "../helpers/workflows"; const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"; const WATERCOLOR_CHANNEL_ID = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"; @@ -58,9 +59,7 @@ async function createWorkflow( await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name); await dialog.getByRole("button", { name: "Save workflow name" }).click(); - await dialog.getByRole("button", { name: "Add step", exact: true }).click(); - await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + await addSendMessageStep(dialog, "Workflow notification"); await dialog.getByRole("button", { name: "Create" }).click(); const activationConfirmation = page.getByRole("alertdialog", { name: "This workflow may run often", diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index ecca4f123e7..90c46a6c4b6 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -2,6 +2,7 @@ import { hexToBytes } from "@noble/hashes/utils.js"; import { expect, test, type Page } from "@playwright/test"; import { nsecEncode, npubEncode } from "nostr-tools/nip19"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, openChannelBrowser, @@ -1521,6 +1522,9 @@ test("hosted community address line stays within the card for a long name", asyn const longName = "a".repeat(63); await communityNameInput.fill(longName); await expect(communityNameInput).toHaveValue(longName); + // The page slides in for 650 ms after the sign-in dialog closes, and the + // three boxes below are read in separate round trips. Measure at rest. + await waitForAnimations(page); const [surfaceBox, inputBox, suffixBox] = await Promise.all([ createSurface.boundingBox(), @@ -2413,6 +2417,9 @@ test("connected first-community profile keeps navigation inside the card and bal await avatarButton.click(); const avatarDialog = page.getByRole("dialog", { name: "Edit your avatar" }); await expect(avatarDialog).toBeVisible(); + // The dialog zooms in from 95% and animates its height; the layout checks + // below read boxes in separate round trips, so take them at rest. + await waitForAnimations(page); await expect(avatarDialog).toHaveAttribute( "data-system-color-scheme", "light", diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 82c44f71be5..d4e21cc3ae7 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Locator, type Page } from "@playwright/test"; import { npubEncode } from "nostr-tools/nip19"; import { @@ -1525,27 +1525,35 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a 0, ); } - await page.getByTestId("user-profile-duplicate-agent-row").click(); + // The Stop and Start success toasts can stack over these rows, and a + // pointer over them pauses their dismissal. Press the rows with the + // keyboard, which the toasts cannot intercept. + const pressActionRow = async (row: Locator) => { + await expect(row).toBeEnabled(); + await row.focus(); + await row.press("Enter"); + }; + await pressActionRow(page.getByTestId("user-profile-duplicate-agent-row")); const duplicateDialog = page.getByTestId("persona-dialog"); await expect(duplicateDialog).toBeVisible(); await duplicateDialog.getByRole("button", { name: "Cancel" }).click(); - await page.getByTestId("user-profile-export-agent-row").click(); + await pressActionRow(page.getByTestId("user-profile-export-agent-row")); const exportDialog = page.getByTestId("agent-snapshot-export-dialog"); await expect(exportDialog).toBeVisible(); await exportDialog.getByRole("button", { name: "Cancel" }).click(); - await page.getByTestId("user-profile-create-card-row").click(); + await pressActionRow(page.getByTestId("user-profile-create-card-row")); const cardMintDialog = page.getByTestId("agent-card-mint-dialog"); await expect(cardMintDialog).toBeVisible(); await page.keyboard.press("Escape"); await expect(cardMintDialog).toHaveCount(0); const archiveAgentRow = page.getByTestId("user-profile-archive-agent-row"); await expect(archiveAgentRow).toHaveText(/Archive agent/); - await archiveAgentRow.click(); + await pressActionRow(archiveAgentRow); await expect(page.getByTestId("archive-confirm-dialog")).toBeVisible(); await page.getByRole("button", { name: "Cancel" }).click(); const deleteAgentRow = page.getByTestId("user-profile-delete-agent-row"); await expect(deleteAgentRow).toHaveText("Delete agent"); - await deleteAgentRow.click(); + await pressActionRow(deleteAgentRow); await expect(page.getByTestId("agent-delete-confirm-dialog")).toBeVisible(); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page.getByTestId("user-profile-tab-memories")).toHaveText( diff --git a/desktop/tests/e2e/projects-v3-screenshots.spec.ts b/desktop/tests/e2e/projects-v3-screenshots.spec.ts index 9a52ae52d86..921070e82c9 100644 --- a/desktop/tests/e2e/projects-v3-screenshots.spec.ts +++ b/desktop/tests/e2e/projects-v3-screenshots.spec.ts @@ -890,6 +890,9 @@ test("projects v3 workspace screenshot states", async ({ page }) => { ).toBeVisible(); await repositoryPanelTab.click(); await expect(repositoryActionsPanel).toBeVisible(); + // The context rail animates from the chat width to the narrower repository + // width, and the workspace widens with it. Measure once it stops. + await waitForAnimations(page); await expectInsetSection(); await page.getByRole("tab", { name: "Commits" }).click(); await expect(repositoryActionsPanel).toBeVisible(); diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index bf3aca2e2d2..83219505b48 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -185,6 +185,16 @@ test("inserts template variables with keyboard control and restores the caret", ), ) .toBe("Hello {{trigger.text}}".length); + // The browser already put the caret at the end, so the poll can pass before + // the insert's queued frame restores it. Let that frame run first: landing + // between fill()'s select-all and its typing, it would collapse the + // selection and append. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); await textarea.fill("Keep {{auth"); await expect(listbox).toBeVisible(); diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 51856be1fba..5bd323abfc7 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -3,6 +3,7 @@ import { parse as parseYaml } from "yaml"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; +import { addSendMessageStep } from "../helpers/workflows"; test.beforeEach(async ({ page }) => { await installMockBridge(page); @@ -100,21 +101,7 @@ async function createWorkflow( .click(); } - await dialog.getByRole("button", { name: "Add step", exact: true }).click(); - await page.getByRole("menuitem", { name: "Send Message" }).click(); - // The inspector swaps from the trigger to the new step after the click; an - // unscoped fill can land in the trigger's message-text condition instead. - // The "Action" header switches at once, but the trigger body stays mounted - // through its exit animation (AnimatePresence mode="wait"), so also wait - // for a control only the step body renders. - const stepInspector = dialog.getByTestId("workflow-node-inspector"); - await expect( - stepInspector.getByRole("button", { name: "Action", exact: true }), - ).toBeVisible(); - await expect( - stepInspector.getByRole("button", { name: "Step details" }), - ).toBeVisible(); - await stepInspector.getByLabel("Message text").fill("Workflow notification"); + await addSendMessageStep(dialog, "Workflow notification"); if (options?.stepName) { await dialog.getByRole("button", { name: "Step details" }).click(); await dialog.getByLabel("Name (optional)").fill(options.stepName); @@ -721,9 +708,7 @@ test("captures the built editor at desktop and narrow widths", async ({ const dialog = page.getByRole("dialog", { name: "Create workflow" }); await selectWorkflowChannel(page, dialog); await editWorkflowName(dialog, "editor_screenshot"); - await dialog.getByRole("button", { name: "Add step", exact: true }).click(); - await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Notify the workflow channel"); + await addSendMessageStep(dialog, "Notify the workflow channel"); const inspector = dialog.getByTestId("workflow-node-inspector"); for (const viewport of [ @@ -826,9 +811,7 @@ test("pane routes use stable IDs and Form/YAML changes stay synchronized", async await selectWorkflowChannel(page, dialog); await editWorkflowName(dialog, "pane_sync_test"); - await dialog.getByRole("button", { name: "Add step", exact: true }).click(); - await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("first message"); + await addSendMessageStep(dialog, "first message"); await expect(page).toHaveURL(/pane=step%3Astep_1/); await dialog.getByRole("button", { name: "Add after Step 1" }).click(); diff --git a/desktop/tests/helpers/workflows.ts b/desktop/tests/helpers/workflows.ts new file mode 100644 index 00000000000..089bbaf2864 --- /dev/null +++ b/desktop/tests/helpers/workflows.ts @@ -0,0 +1,23 @@ +import { expect, type Locator } from "@playwright/test"; + +/** + * Adds a Send Message step to an open workflow dialog and fills its text. + * + * The inspector swaps from the trigger to the new step after the click; an + * unscoped fill can land in the trigger's message-text condition instead. + * The "Action" header switches at once, but the trigger body stays mounted + * through its exit animation (AnimatePresence mode="wait"), so also wait + * for a control only the step body renders. + */ +export async function addSendMessageStep(dialog: Locator, text: string) { + await dialog.getByRole("button", { name: "Add step", exact: true }).click(); + await dialog.page().getByRole("menuitem", { name: "Send Message" }).click(); + const stepInspector = dialog.getByTestId("workflow-node-inspector"); + await expect( + stepInspector.getByRole("button", { name: "Action", exact: true }), + ).toBeVisible(); + await expect( + stepInspector.getByRole("button", { name: "Step details" }), + ).toBeVisible(); + await stepInspector.getByLabel("Message text").fill(text); +} From 389ee76ed5b9a324f6bc224c0eb3fc2b5bbdda3a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 30 Oct 2026 20:40:18 +1100 Subject: [PATCH 2/2] test(desktop): clear remote-owned-mentions' A draft by Backspace, not fill("") Unit 14 of the 23-30 Oct flake audit, found in round 3's CI sweep. It is test-only. origin/main (1eb67bb52) hasn't touched this spec since fae3f0346, and no open PR touches it. remote-owned-mentions.spec.ts:566, "ordinary failure after returning to A and deleting does not resurrect storage" Run: main 40998438103 (158b3bf56, #8727), Desktop Smoke E2E (9), job 123877347301, attempt 1. It passed on retry, and it failed 1 of the 6 attempts it got in the window. 158b3bf56 already carries #8682. Signature: the sourceRecord() check at :673 expects null and receives thread A's draft, {"content": "new authored text", "selectionStart": 17, "selectionEnd": 17, "status": "active", ...}. Cause: the test writes on A with fill("new authored text") and clears it with fill("") (:649-650). On the ProseMirror composer, fill("") selects the contents through the DOM and then presses Delete. If the Delete arrives before ProseMirror handles selectionchange, ProseMirror still sees a collapsed caret at the end and blocks it. clearComposer in tests/helpers/composer.ts documents the same race. The text stays, and the editor never emits the empty update that sets emptyContentIsAuthoritativeRef (useDraftPersistSnapshot.ts:256-275). :662 still reads null, because non-empty content is only persisted when the draft key changes. Navigating to B then runs the layout-effect cleanup (useDraftPersistSnapshot.ts:229-253), which saves the editor's live text as A's draft through persistDraftEntry. The record fits that path: createdAt is the moment of navigation, and selectionStart is the content length, which persistDraftEntry sets on every save (useDrafts.ts:514-515). #8682 moved the sibling test to replaceComposerText plus clearComposer (:525-526) but missed :650. This is not a product bug: had the editor really been empty, the cleanup would have cleared A's draft. Change: keep the first fill, which writes into an empty document, and wait for its text. Then delete it with one Backspace per character and wait for the composer to read empty. The caret is already at the end, so no DOM selection has to reach ProseMirror, and a late focus-to-end changes nothing. A clear that doesn't take now fails at the new toHaveText(""), not as a resurrected draft at :673. The guard assertions at :662, :673 and :691 are unchanged. clearComposer would match the sibling test, but it repeats select-all plus Delete in a toPass loop. Backspace from the end needs no retry. Verification: biome check on the spec is clean. An ad hoc tsc ran over the spec, using the desktop tsconfig with only that file, and over HEAD's copy of it. Both gave 22 errors, 8 of them in the spec, and the messages are identical once line numbers are stripped. All are pre-existing (window.__BUZZ_E2E__ possibly undefined, import.meta.env, no Node types, MockBridgeOptions drift and the like). Playwright was not run locally. Hosted CI is the check: Flake Check repeats the Playwright specs a PR changes, so it will repeat this one. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- desktop/tests/e2e/remote-owned-mentions.spec.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/desktop/tests/e2e/remote-owned-mentions.spec.ts b/desktop/tests/e2e/remote-owned-mentions.spec.ts index 63f2af7012c..1dd711d076d 100644 --- a/desktop/tests/e2e/remote-owned-mentions.spec.ts +++ b/desktop/tests/e2e/remote-owned-mentions.spec.ts @@ -646,8 +646,16 @@ for (const incoming of ["B preserved"]) { await expect(input).toHaveText(incoming); await navigate(roots[0]); await expect(input).toHaveAttribute("data-lifecycle-host", "retained"); - await input.fill("new authored text"); - await input.fill(""); + const authored = "new authored text"; + await input.fill(authored); + await expect(input).toHaveText(authored); + // fill("") can leave ProseMirror's text in place (see clearComposer), and + // leaving A would then save it as A's draft. Backspace from the caret at + // the end needs no DOM-selection hand-off. + for (let i = 0; i < authored.length; i += 1) { + await input.press("Backspace"); + } + await expect(input).toHaveText(""); const sourceRecord = () => page.evaluate(([root, otherRoot]) => { const key = Object.keys(localStorage).find((key) =>