Files
buzz/desktop/tests/e2e/thread-follow.spec.ts
Matt Toohey 428f0dcd22 test(desktop): settle composer edits and press hover-only row actions by keyboard (#8682)
## Summary

Test-side fixes for the two largest desktop E2E flake clusters in the
21–28 Oct 2026 CI audit:

- **Composer `fill()` races the app's deferred focus-to-end** (§1). Text
was appended to the old draft, or a clear did nothing.
- **Clicks on the hover-only message action bar while the timeline
re-pins** (§3/§8/§18). Reply and more-actions clicks timed out or were
intercepted.

Two shared helpers replace the per-spec workarounds, and §9 rides along
because its spec now imports them. The PR changes test code only. It is
one of seven PRs split out of the `flakey-tests` branch (siblings
below).

### Related issue

None found. #8559, #8622 and #8613 touch these specs or helpers, but had
no coverage for these clusters. Draft #8661 edits
`empty-edit-delete.spec.ts`'s `submitEmptyEdit` and `beforeEach`, the
same lines as this PR.

### 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).
- `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` clicks `#general`, then calls
`waitForMockChannelHeadReady`.
- `pressRowAction(button)` does focus + Enter. The action bar also shows
on focus-within, and more-actions is a Radix trigger that opens on
Enter.

### Flaky tests fixed

| Test | Failing runs | Cause | Change |
|---|---|---|---|
| §1 `inbox-edit.spec.ts:525` | 39641311877, 39821769576, 40333705511,
40348069929, 40388246283 | **Append:** entering edit mode, or closing
the non-member mention dialog, focuses the end of the draft in deferred
steps: RAF, a nested RAF, then ProseMirror's 20 ms selection resync.
`fill()` selects and inserts in separate protocol steps, so a step that
lands in between collapses the selection and the text is appended.
**Clear does nothing:** `fill("")` presses Delete before ProseMirror
handles `selectionchange`, so the editor blocks it. | `clearComposer` /
`replaceComposerText` |
| §1 `inbox-edit.spec.ts:560` | 39538411386, 39821769576 | Same as
above. | Same |
| §1 `mention-recipients.spec.ts:591` (both variants) | 39799090750,
40374286094, 39641311877 | Same | Same |
| §1 `mention-recipients.spec.ts:691` (both variants) | 40348069929,
40556019361, 39601763602 | Same | Same |
| §1 `messaging.spec.ts:3799` | 39827234118, 40445481983 | Same, plus a
bare `#general` click and a `force: true` Reply | Same, plus
`openGeneral` and `pressRowAction` |
| §1 `messaging.spec.ts:4022` | 40389392495 | Same | Same |
| §1 `messaging.spec.ts:4289` | 39822883395, 40557808800, 40566841699 |
Same | Same |
| §1 `messaging.spec.ts:4368` | 40574058918 | Same | Same |
| §1 `remote-owned-mentions.spec.ts:463` (both variants) | 39618692657,
40374286094, 40382726903, 40389392495, 40582387515 | Same, after the
Invite dialog closes | Same |
| §3 `messaging.spec.ts` :3531, :3647, :4122, :4208, :4450 (root and
reply) and :4633 (keyboard and button) | :3531 40583830861, 40595483966.
:3647 40566841699, 40597484657. :4122 39641311877, 40368315739,
40368363357. :4208 39525092486, 39796569342, 40367495044. :4450
39796569342, 40348069929, 40373404267. :4633 39530486096, 40348069929,
40368315739, 40582387515, 40368363357 | The 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. `force: true` only
hid this. | `openGeneral`, then `pressRowAction` on Reply or
more-actions. Six more messaging tests that used hover + `click({ force:
true })` get the same change, and each first waits for its last row to
contain the sent root. |
| §18 `thread-follow.spec.ts:350` | 39529963092, 40348069929,
40583830861 (all 3), 40595483966; PR 39541263507 (all 3) | Same; the
composer overlay intercepts the more-actions click. | Same |
| §8 `thread-head-stale-edit.spec.ts:55` | 39525092486, 39618692657,
39796569342, 39822883395, 40011714848, 40281448810, 40368363357,
40389392495, 40445481983, 40574058918; PR 40031299575 (all 3) | Same
(used `force: true`) | Same |
| `file-attachment.spec.ts:165` | 1–2 CI failures (audit long tail) | A
more-actions click on a moving timeline | Same |
| `empty-edit-delete.spec.ts` (`openMoreActionsMenu`, `submitEmptyEdit`,
the non-empty edit, `beforeEach`) | Covered under §11 in the
desktop-races sibling | The same hover and `fill` races | The head-ready
wait before emitting, `pressRowAction`, `clearComposer` and
`replaceComposerText`. The autocomplete table rows keep their
back-to-back keys, which is what they test. |
| §9 `file-attachment.spec.ts:419` "upload progress floats above the
dock and lifts Jump to latest" | 10 of 50 main jobs, including
40583830861, 40597484657, 40608383847 | A programmatic `scrollTop` keeps
the bottom intent armed. The upload card resizes the content, the
timeline re-pins, and the pill unmounts under `boundingBox()`. The fixed
250 ms wait read the card mid-slide. | `scrollTimelineUp()` leaves the
bottom with a real wheel, and one `expect.poll` checks all three layout
relations. |

Local before/after counts are from the original `flakey-tests` commits
(e286a6ae0, 816c7c35e, c66f411f3, 67185df6a), running `main`'s copy and
the changed copy side by side under forcings:

- **§1 at 4× CPU throttle:** 130/130 after; `main`'s copies ranged from
5/10 to 10/10.
- **§1 with `selectionchange` delayed, a stretched resync, or a gap
inside `fill()`:** `main`'s copies 0/5 to 4/5 and 0/20; after 65/65 and
20/20.
- **§3 at 4× throttle:** `main` had 3 Reply-click timeouts at :4208 and
3 at :4633 (keyboard); after 120/120.
- **`file-attachment:165`:** 0/3 before, 3/3 after.
- **§9 unforced:** 4/5 before, 5/5 after.

These were measured with the helper's first version, which waited two
frames plus the 20 ms resync. The final state-based helper ran unforced
only: 157/157 and then 196/196 over the touched specs. Its retry was not
checked under a forced late collapse. :3531, :3647, :4122, :4450 root,
:4633 button, `thread-head-stale-edit:55` and the six force-click tests
were not force-reproduced; they get the fix that matches their CI
signatures and reproduced siblings.

Dropped:

- `mention-recipients.spec.ts:933` (40608383847) fails on
`flavors.handled`, which is unrelated.
- `persistent-agent-audience.spec.ts:828` (`@Mor@Morg`) did not
reproduce in 79 forced runs.

### Verification

This branch was pushed without local re-verification, by the user's
choice: local test runs were overloading the machine and failing for
that reason. **Hosted CI is the check.** It was pushed with `git push
--no-verify`, because the pre-push hook runs typecheck and the unit
suites. #8613's flake-check lane will also repeat the changed specs.

- Every file except `empty-edit-delete.spec.ts` is byte-identical to its
final version on `flakey-tests` (67185df6a). That file carries only this
PR's hunks; see "Merge order".
- Checked for this PR: `git diff --check`, biome on the partial spec (no
changes, the same as on `main`), and a conflict-free merge with all six
siblings onto `main`.

### Readiness

Test-only change. AGENTS.md's readiness steps (agent review, then human
confirmation) are still outstanding, so `buzz-review-completed` is not
set.

### Merge order

No required order. `desktop/tests/e2e/empty-edit-delete.spec.ts` is also
edited by **flakey-tests-desktop-races**, which adds the autocomplete
table after line 158. This PR changes imports, helpers, `beforeEach` and
line 143. The hunks don't touch, so the two merge cleanly in either
order, as checked with `git merge-tree`. Draft #8661 edits the same
`submitEmptyEdit`/`beforeEach` lines; whichever lands second will need a
rebase.

### Sibling PRs

Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease +
lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679
(Tauri apt cache + Windows Rust cap), #8680 (desktop product races),
#8681 (desktop E2E state gates).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:59:11 +11:00

430 lines
14 KiB
TypeScript

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);
const STORAGE_KEY = `buzz-thread-follows.v1:${SELF_PUBKEY}`;
const SCREENSHOTS = "test-results/thread-follow";
type MockMessage = { id: string; created_at: number; pubkey: string };
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(() =>
page.evaluate(
(name) =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false,
channelName,
),
)
.toBe(true);
}
async function emitMessage(
page: import("@playwright/test").Page,
input: {
channelName?: string;
content: string;
parentEventId?: string;
createdAt?: number;
extraTags?: string[][];
id?: string;
pending?: boolean;
pubkey?: string;
},
): Promise<MockMessage> {
const message = await page.evaluate(
(payload) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: payload.channelName,
content: payload.content,
parentEventId: payload.parentEventId,
createdAt: payload.createdAt,
extraTags: payload.extraTags,
id: payload.id,
pending: payload.pending,
pubkey: payload.pubkey,
}),
{
...input,
channelName: input.channelName ?? CHANNEL,
pubkey: input.pubkey ?? TEST_IDENTITIES.alice.pubkey,
},
);
if (!message) throw new Error("mock message emitter is unavailable");
return message;
}
async function expectNoThreadFollowControls(
page: import("@playwright/test").Page,
messageId: string,
) {
await openMessageMenu(page, messageId);
await expect(
page.getByRole("menuitem", { name: /^(Unfollow|Follow) thread$/ }),
).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function openMessageMenu(
page: import("@playwright/test").Page,
messageId: string,
) {
const row = page.locator(
`[data-testid="message-row"][data-message-id="${messageId}"]`,
);
await row.evaluate((element) => element.scrollIntoView({ block: "center" }));
await pressRowAction(row.getByTestId(`more-actions-${messageId}`));
await expect(page.getByRole("menu")).toBeVisible();
}
async function scrollBackFromLatest(page: import("@playwright/test").Page) {
await page.locator("[data-scroll-restoration-id] .overflow-y-auto").hover();
await page.mouse.wheel(0, -2000);
await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
}
async function storedFollowIds(page: import("@playwright/test").Page) {
return page.evaluate((key) => {
const entries = JSON.parse(localStorage.getItem(key) ?? "[]") as Array<{
rootId: string;
}>;
return entries.map((entry) => entry.rootId);
}, STORAGE_KEY);
}
async function captureMessageMenu(
page: import("@playwright/test").Page,
messageId: string,
path: string,
) {
await waitForAnimations(page);
const rowBox = await page
.locator(`[data-testid="message-row"][data-message-id="${messageId}"]`)
.boundingBox();
const menuBox = await page.getByRole("menu").boundingBox();
const viewport = page.viewportSize();
if (!rowBox || !menuBox || !viewport) {
throw new Error("message menu screenshot bounds are unavailable");
}
const padding = 16;
const x = Math.max(0, Math.min(rowBox.x, menuBox.x) - padding);
const y = Math.max(0, Math.min(rowBox.y, menuBox.y) - padding);
const right = Math.min(
viewport.width,
Math.max(rowBox.x + rowBox.width, menuBox.x + menuBox.width) + padding,
);
const bottom = Math.min(
viewport.height,
Math.max(rowBox.y + rowBox.height, menuBox.y + menuBox.height) + padding,
);
await page.screenshot({
path,
clip: { x, y, width: right - x, height: bottom - y },
});
}
async function notificationBodies(page: import("@playwright/test").Page) {
return page.evaluate(() =>
(window.__BUZZ_E2E_NOTIFICATIONS__ ?? []).map((entry) => entry.body),
);
}
async function waitForMessageProcessing(
page: import("@playwright/test").Page,
messageId: string,
) {
await expect
.poll(() =>
page.evaluate((id) => {
const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as {
getQueriesData: (filter: unknown) => Array<[unknown, unknown]>;
};
return queryClient
.getQueriesData({ queryKey: [] })
.some(([, data]) => (JSON.stringify(data) ?? "").includes(id));
}, messageId),
)
.toBe(true);
}
test("a delivered message can be followed before its first reply and unfollowed", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL);
await waitForMockLiveSubscription(page, CHANNEL);
const rootId = "mock-general-alice";
await expect(
page.locator(`[data-testid="message-row"][data-message-id="${rootId}"]`),
).toContainText("Hey team — checking in.");
await openMessageMenu(page, rootId);
const followItem = page.getByRole("menuitem", { name: "Follow thread" });
await expect(followItem).toBeVisible();
await captureMessageMenu(page, rootId, `${SCREENSHOTS}/follow-thread.png`);
await followItem.focus();
await page.keyboard.press("Enter");
await expect.poll(() => storedFollowIds(page)).toEqual([rootId]);
await page.reload();
await page.getByTestId(`channel-${CHANNEL}`).click();
await waitForMockLiveSubscription(page, CHANNEL);
await expect.poll(() => storedFollowIds(page)).toEqual([rootId]);
await openMessageMenu(page, rootId);
const unfollowItem = page.getByRole("menuitem", {
name: "Unfollow thread",
});
await expect(unfollowItem).toBeVisible();
await captureMessageMenu(page, rootId, `${SCREENSHOTS}/unfollow-thread.png`);
await page.keyboard.press("Escape");
await page.getByTestId("channel-random").click();
await emitMessage(page, {
content: "First reply after follow",
parentEventId: rootId,
createdAt: Math.floor(Date.now() / 1000) + 60,
});
await expect
.poll(() => notificationBodies(page))
.toContain("First reply after follow");
await expect(page.getByTestId(`channel-unread-dot-${CHANNEL}`)).toBeVisible();
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL);
await openMessageMenu(page, rootId);
await unfollowItem.focus();
await page.keyboard.press("Enter");
await expect.poll(() => storedFollowIds(page)).toEqual([]);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId(`channel-unread-dot-${CHANNEL}`)).toHaveCount(
0,
);
const notificationCount = (await notificationBodies(page)).length;
const mutedReply = await emitMessage(page, {
content: "Later ordinary reply after unfollow",
parentEventId: rootId,
createdAt: Math.floor(Date.now() / 1000) + 120,
});
await waitForMessageProcessing(page, mutedReply.id);
await expect(page.getByTestId(`channel-unread-dot-${CHANNEL}`)).toHaveCount(
0,
);
await emitMessage(page, {
content: "Control reply after muted reply",
parentEventId: "mock-general-welcome",
createdAt: Math.floor(Date.now() / 1000) + 180,
});
await expect
.poll(() => notificationBodies(page))
.toContain("Control reply after muted reply");
const finalNotificationBodies = await notificationBodies(page);
expect(finalNotificationBodies).toHaveLength(notificationCount + 1);
expect(finalNotificationBodies).not.toContain(
"Later ordinary reply after unfollow",
);
});
test("following a broadcast reply persists its thread root", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await waitForMockLiveSubscription(page, CHANNEL);
const pending = await emitMessage(page, {
content: "Pending message has no durable thread id",
pending: true,
});
await openMessageMenu(page, pending.id);
await expect(
page.getByRole("menuitem", { name: /^(Unfollow|Follow) thread$/ }),
).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).toHaveCount(0);
await scrollBackFromLatest(page);
const root = await emitMessage(page, { content: "Broadcast thread root" });
const broadcastReply = await emitMessage(page, {
content: "Broadcast reply row",
parentEventId: root.id,
extraTags: [["broadcast", "1"]],
});
await waitForMessageProcessing(page, broadcastReply.id);
await expect(
page.locator(
`[data-testid="message-row"][data-message-id="${broadcastReply.id}"]`,
),
).toHaveCount(0);
await expect(page.getByTestId("message-scroll-to-latest")).toContainText("2");
await page.getByTestId("message-scroll-to-latest").click();
await openMessageMenu(page, broadcastReply.id);
const followItem = page.getByRole("menuitem", { name: "Follow thread" });
await followItem.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("menu")).toHaveCount(0);
await expect.poll(() => storedFollowIds(page)).toEqual([root.id]);
expect(await storedFollowIds(page)).not.toContain(broadcastReply.id);
await scrollBackFromLatest(page);
const child = await emitMessage(page, {
content: "Child of broadcast reply",
parentEventId: broadcastReply.id,
});
await waitForMessageProcessing(page, child.id);
await expect(
page.locator(`[data-thread-head-id="${broadcastReply.id}"]`),
).toHaveCount(0);
await expect(page.getByTestId("message-scroll-to-latest")).toContainText("1");
await page.getByTestId("message-scroll-to-latest").click();
await expect(
page.locator(`[data-thread-head-id="${broadcastReply.id}"]`),
).toBeVisible();
await page.getByTestId(`more-actions-${broadcastReply.id}`).focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("menu")).toBeVisible();
const unfollowItem = page.getByRole("menuitem", {
name: "Unfollow thread",
});
await expect(unfollowItem).toBeVisible();
await unfollowItem.focus();
await page.keyboard.press("Enter");
await expect.poll(() => storedFollowIds(page)).toEqual([]);
});
test("a delivered self-authored root immediately offers unfollow", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await waitForMockLiveSubscription(page, CHANNEL);
const root = await emitMessage(page, {
content: "Root written by the current user",
pubkey: SELF_PUBKEY,
});
await waitForMessageProcessing(page, root.id);
await openMessageMenu(page, root.id);
await expect(
page.getByRole("menuitem", { name: "Unfollow thread" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Follow thread", exact: true }),
).toHaveCount(0);
await expect.poll(() => storedFollowIds(page)).toEqual([]);
const unfollowItem = page.getByRole("menuitem", {
name: "Unfollow thread",
});
await unfollowItem.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("menu")).toHaveCount(0);
await openMessageMenu(page, root.id);
await expect(
page.getByRole("menuitem", { name: "Follow thread", exact: true }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Unfollow thread" }),
).toHaveCount(0);
await expect.poll(() => storedFollowIds(page)).toEqual([]);
});
test("a mention-only root offers follow and explicit follow enables ordinary reply notifications", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await openGeneral(page);
await waitForMockLiveSubscription(page, CHANNEL);
const root = await emitMessage(page, {
content: "Thread root before mention",
});
const mention = await emitMessage(page, {
content: "Mention-only thread reply",
parentEventId: root.id,
extraTags: [["p", SELF_PUBKEY]],
});
await waitForMessageProcessing(page, mention.id);
await openMessageMenu(page, root.id);
const followItem = page.getByRole("menuitem", {
name: "Follow thread",
exact: true,
});
await expect(followItem).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Unfollow thread" }),
).toHaveCount(0);
await followItem.click();
await expect(page.getByRole("menu")).toHaveCount(0);
await expect.poll(() => storedFollowIds(page)).toEqual([root.id]);
await page.getByTestId("channel-random").click();
await emitMessage(page, {
content: "Ordinary reply after explicit follow",
parentEventId: root.id,
createdAt: Math.floor(Date.now() / 1000) + 60,
});
await expect
.poll(() => notificationBodies(page))
.toContain("Ordinary reply after explicit follow");
});
test("a plain DM message omits thread follow controls", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
await waitForMockLiveSubscription(page, "alice-tyler");
const root = await emitMessage(page, {
channelName: "alice-tyler",
content: "Plain DM root",
pubkey: SELF_PUBKEY,
});
await waitForMessageProcessing(page, root.id);
await expectNoThreadFollowControls(page, root.id);
});
test("a DM thread summary omits thread follow controls", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
await waitForMockLiveSubscription(page, "alice-tyler");
const root = await emitMessage(page, {
channelName: "alice-tyler",
content: "DM thread root",
});
const reply = await emitMessage(page, {
channelName: "alice-tyler",
content: "DM thread reply",
parentEventId: root.id,
});
await waitForMessageProcessing(page, reply.id);
await expect(
page.locator(`[data-thread-head-id="${root.id}"]`),
).toBeVisible();
await expectNoThreadFollowControls(page, root.id);
});