test(desktop): wait on real readiness signals in audience and community-rail specs (#9277)

## 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 <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-11-10 08:38:40 +11:00
committed by GitHub
parent 2b16d0ea1a
commit 15c2ca1b01
2 changed files with 43 additions and 3 deletions

View File

@@ -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 ({

View File

@@ -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();