From 15c2ca1b018dfb959eb9221f168952df812b4850 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 10 Nov 2026 08:38:40 +1100 Subject: [PATCH] test(desktop): wait on real readiness signals in audience and community-rail specs (#9277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Split out of the `flakey-monday` flake-fix series. Three Desktop E2E tests in `persistent-agent-audience.spec.ts` and `community-rail.spec.ts` acted on signals that could arrive before the app state they implied (a logged command before the submit lock rendered, a URL check that only held before a view transition landed). They now wait on the readiness signals the product actually exposes. No product code changes. Commits (cherry-picked from `flakey-monday`, original messages kept): - `test(desktop): wait for composer focus and send cycles in audience specs` - `test(desktop): assert the kept remembered route after the community switch` - `test(desktop): assert Home after repairing the remembered channel` ### Root cause and fix per test **`a manual mention persists when automatic mentions are enabled`** (`desktop/tests/e2e/persistent-agent-audience.spec.ts`): `locator.fill("follow up")` failed because `message-input` was not editable. - Root cause (from the code and CI symptom; not reproduced locally): the test sends with a 1.5 s mock send delay, during which the composer is submit-locked and `contenteditable="false"`. The lock is React state and renders a frame after Enter. The test's last two checks before the fill could both pass before that frame: the outgoing-mention poll (the e2e bridge logs a command when invoked, before the delay) and the `contenteditable="true"` wait. Playwright's `fill` rejects a `contenteditable="false"` element outright instead of waiting. - Fix: after Enter, wait for the full send cycle: `data-submit-locked` "true", `contenteditable` "false", `data-submit-locked` "false", `contenteditable` "true". Only then assert on the editor. Existing assertions are unchanged. **`the mention setting is reachable and operable by keyboard`** (same spec): the thread composer's mention autocomplete never became visible. - Root cause (likely; not reproduced locally): opening a thread by URL mounts both the channel composer and the thread composer. `useComposerAutofocus` focuses whichever mounts first and TipTap defers `focus()` by a frame. A click and fill that land before that deferred focus lose it, and the focus-gated mention menu unmounts. - Fix: a `readyThreadComposerInput()` helper waits for the thread editor to be editable and for exactly one `message-input` to hold focus, so the app's own autofocus has landed. Both tests use it. **`community rail > does not repair a remembered channel until live validation succeeds`** (`desktop/tests/e2e/community-rail.spec.ts`): `expect(page).not.toHaveURL(/#\/channels\/general$/)` failed right after the click on community B. - Root cause (confirmed; 21/40 failures at 14 workers on the old spec): `switchCommunity` runs inside a view transition whose callback writes B's remembered channel into the hash and then activates B, a few frames after the click returns. By design the URL is then `#/channels/general`, which `AppShell` keeps until a live channel read succeeds and only then repairs to Home. The inverted check from 631b05c88 passed only when its first poll beat the view transition. - Fix: wait for B's rail button to have `aria-current="true"` (switch finished), then assert the URL is `#/channels/general`, the kept route. After live validation repairs storage to Home, assert the Home route `/#\/$/` (the follow-up commit; `not.toHaveURL(general)` would have passed for any route, and `AppShell`'s repair calls `goHome({ replace: true })`). ### Related issue N/A (flake fixes; none found). ### Testing Human testing is still required. This PR does not carry `buzz-review-completed`; the author has not yet run these specs themselves on this branch. #### Verification Verified locally on the `flakey-monday` branch before the split (Playwright smoke project, e2e build): - Audience spec: neither flake reproduced before the fix, even with CPU throttled 4x and 8x. After the fix, both tests 40 repeats each at 10 workers: 80/80; whole spec: 35/35. - Community-rail test: 40 repeats at 14 workers: 40/40; whole spec: 27/27 at 4 workers. The Home assertion was falsified: with the repair temporarily sending the app to `#/channels/random`, the new assertion failed and the previous `not.toHaveURL` check passed. - The four desktop flake tests from the series together, 10 repeats each at 8 workers: 40/40. - Two untouched tests in these specs are pre-existing flakes a baseline build fails as often, and are out of scope: community-rail "enters a remembered channel before live validation completes" and persistent-agent-audience "the mention button preserves manual rementions after undo until explicitly readdressed". Nothing was re-run on this split branch: the workstation was overloaded, so no tests, builds or linters were executed after cherry-picking. CI on this PR is the verification for the split branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: mtoohey Co-authored-by: Claude Opus 5.5 --- desktop/tests/e2e/community-rail.spec.ts | 13 +++++++- .../e2e/persistent-agent-audience.spec.ts | 33 +++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index dc733ed234..938402437f 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -934,7 +934,16 @@ test.describe("community rail", () => { ), ).toBe(1); await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); - await expect(page).not.toHaveURL(/#\/channels\/general$/); + // The switch runs inside a view transition, so its update (which writes the + // remembered channel into the route, then activates the community) lands + // frames after the click returns. Wait for it to finish before judging the + // route; checking the URL right after the click only passes when it has not + // run yet. The remembered route is kept until the live read validates it, + // so the channel absent from the cached list is not repaired away. + await expect( + page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), + ).toHaveAttribute("aria-current", "true"); + await expect(page).toHaveURL(/#\/channels\/general$/); await expect .poll(() => page.evaluate((communityId) => { @@ -1021,6 +1030,8 @@ test.describe("community rail", () => { }, COMMUNITY_B.id), ) .toEqual({ kind: "home" }); + // Live validation succeeded, so the missing channel is now repaired to Home. + await expect(page).toHaveURL(/#\/$/); }); test("does not restore a remembered destination on cold boot", async ({ diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 1b402c993c..23f0c1d145 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -90,6 +90,23 @@ function threadComposer(page: Page) { return page.getByTestId("thread-composer-overlay"); } +/** + * Returns the thread composer's editor once the app has finished placing + * focus on its own. Opening a thread by URL mounts the channel and thread + * composers, and whichever mounts first autofocuses itself a frame later + * (TipTap's `focus()` is deferred). A click that lands before that frame is + * undone by the late autofocus, which blurs the editor the test just typed in + * and unmounts its focus-gated mention menu. + */ +async function readyThreadComposerInput(page: Page) { + const input = threadComposer(page).getByTestId("message-input"); + await expect(input).toHaveAttribute("contenteditable", "true"); + await expect(page.locator('[data-testid="message-input"]:focus')).toHaveCount( + 1, + ); + return input; +} + async function readComposerCaret(input: Locator) { return input.evaluate((element) => { const selection = window.getSelection(); @@ -833,7 +850,7 @@ test("the mention setting is reachable and operable by keyboard", async ({ await openThread(page); const mainComposer = threadComposer(page); - const mainInput = mainComposer.getByTestId("message-input"); + const mainInput = await readyThreadComposerInput(page); await mainInput.click(); await mainInput.fill("@Mor"); const list = mainComposer.getByTestId("mention-autocomplete"); @@ -934,7 +951,7 @@ test("a manual mention persists when automatic mentions are enabled", async ({ await openThread(page); const composer = threadComposer(page); - const input = composer.getByTestId("message-input"); + const input = await readyThreadComposerInput(page); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); await input.press("Tab"); @@ -987,6 +1004,18 @@ test("a manual mention persists when automatic mentions are enabled", async ({ await input.type("hello"); await input.press("Enter"); + // The editor locks one frame after Enter (isSending disables it, blurring + // it) and unlocks when the delayed send settles. Wait for that whole cycle + // before asserting on the editor; its focus and editability checks all hold + // vacuously in the frame before the lock lands. + const composerForm = composer.getByTestId("message-composer"); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + await expect(input).toHaveAttribute("contenteditable", "false"); + await expect(composerForm).toHaveAttribute("data-submit-locked", "false", { + timeout: 5_000, + }); + await expect(input).toHaveAttribute("contenteditable", "true"); + await expect(input).toHaveText("@Morgarita ", { timeout: 2_500 }); await expect(input.locator("[data-placeholder]")).toHaveCount(0); await expect(input).toBeFocused();