mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
## 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>
172 lines
6.4 KiB
TypeScript
172 lines
6.4 KiB
TypeScript
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
|
|
// original. The thread head is taken from the channel window (which carries the
|
|
// edit) but its overlay was previously dropped, relying solely on the async
|
|
// thread-reply aux backfill — so before that fetch lands the head renders stale.
|
|
//
|
|
// We reproduce the divergence deterministically by *gating* the thread-replies
|
|
// fetch (`deferThreadReplies`): the channel window already holds the edit, but
|
|
// the thread-aux response is held open — it provably cannot land until the test
|
|
// releases it. This is stronger than a timed delay: a 4s timer self-heals
|
|
// inside Playwright's auto-retry window, so on the buggy code the delayed aux
|
|
// backfill could arrive mid-assertion and false-green. A held gate cannot.
|
|
|
|
const CHANNEL = "general";
|
|
const SHOT = "test-results/thread-head-stale-edit";
|
|
|
|
type MockMessageWindow = Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
|
channelName: string;
|
|
content: string;
|
|
parentEventId?: string | null;
|
|
pubkey?: string;
|
|
kind?: number;
|
|
extraTags?: string[][];
|
|
}) => { id: string; created_at: number; pubkey: string } | undefined;
|
|
};
|
|
|
|
async function waitForMockLiveSubscription(
|
|
page: import("@playwright/test").Page,
|
|
channelName: string,
|
|
) {
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(ch) =>
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
|
channelName: string;
|
|
}) => boolean;
|
|
}
|
|
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
|
|
false,
|
|
channelName,
|
|
),
|
|
)
|
|
.toBe(true);
|
|
}
|
|
|
|
test("thread head reflects the channel-window edit even before thread aux loads", async ({
|
|
page,
|
|
}) => {
|
|
// Hold the thread-replies (and its aux edit backfill) response open so it
|
|
// provably cannot land until we release it after asserting the head.
|
|
await installMockBridge(page, { deferThreadReplies: true });
|
|
await page.goto("/");
|
|
// 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);
|
|
|
|
const ORIGINAL = "can i get a review on these two PRs? (pr 6706, pr 6701)";
|
|
const EDITED = "can i get a review on these PRs? (pr 6706, pr 6701, pr 6503)";
|
|
|
|
// 1. Post a top-level message, then edit it — both land in the channel window.
|
|
// The message MUST be authored by the active identity (tyler): an edit is
|
|
// only overlaid onto a message when the edit's signer matches the target's
|
|
// author (formatTimelineMessages authorization), and the mock `edit_message`
|
|
// command signs with the active identity.
|
|
const root = await page.evaluate(
|
|
({ channelName, content, pubkey }) =>
|
|
(window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName,
|
|
content,
|
|
pubkey,
|
|
}) ?? null,
|
|
{
|
|
channelName: CHANNEL,
|
|
content: ORIGINAL,
|
|
pubkey: TEST_IDENTITIES.tyler.pubkey,
|
|
},
|
|
);
|
|
expect(root?.id).toBeTruthy();
|
|
const rootId = root?.id;
|
|
if (!rootId) throw new Error("mock message emit did not return an id");
|
|
|
|
await page.evaluate(
|
|
({ channelName, rootId, content, pubkey, editKind }) =>
|
|
(window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName,
|
|
content,
|
|
pubkey,
|
|
kind: editKind,
|
|
// kind:40003 edit event targeting the root — the same shape the relay
|
|
// delivers into the channel-window subscription.
|
|
extraTags: [["e", rootId]],
|
|
}),
|
|
{
|
|
channelName: CHANNEL,
|
|
rootId,
|
|
content: EDITED,
|
|
pubkey: TEST_IDENTITIES.tyler.pubkey,
|
|
editKind: 40003,
|
|
},
|
|
);
|
|
|
|
// 2. The main timeline row shows the edited body (baseline: edit is applied
|
|
// in the channel window).
|
|
const timelineRow = page.locator(
|
|
`[data-testid="message-row"][data-message-id="${rootId}"]`,
|
|
);
|
|
await expect(timelineRow.getByTestId("message-body")).toContainText(
|
|
"these PRs?",
|
|
);
|
|
await expect(timelineRow.getByTestId("message-body")).not.toContainText(
|
|
"these two PRs?",
|
|
);
|
|
|
|
// 3. Open the thread via the reply action (the flow in the bug report).
|
|
await pressRowAction(page.getByTestId(`reply-message-${rootId}`));
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
// 4. The thread head must show the EDITED body immediately — while the
|
|
// thread-aux backfill is still gated (provably not yet delivered). Pre-fix
|
|
// this rendered the stale original ("these two PRs?"), and because the gate
|
|
// is held (not merely delayed) no backfill can arrive to heal it.
|
|
const headBody = threadPanel
|
|
.locator(`[data-testid="message-row"][data-message-id="${rootId}"]`)
|
|
.getByTestId("message-body");
|
|
await expect(headBody).toContainText("these PRs?");
|
|
await expect(headBody).not.toContainText("these two PRs?");
|
|
|
|
// The thread-aux fetch must actually be held: this proves the edited head
|
|
// above came purely from the channel-window overlay, not from a backfill that
|
|
// slipped in. (Guards against the fetch never having been dispatched.)
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_THREAD_REPLIES_PENDING__?: () => number;
|
|
}
|
|
).__BUZZ_E2E_THREAD_REPLIES_PENDING__?.() ?? 0,
|
|
),
|
|
)
|
|
.toBeGreaterThan(0);
|
|
|
|
await waitForAnimations(page);
|
|
await page.screenshot({ path: `${SHOT}/thread-head-edited.png` });
|
|
|
|
// 5. Release the gate; the held thread-aux backfill now lands and the head
|
|
// stays edited (dedup against relay-provided aux, no regression).
|
|
await page.evaluate(() =>
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_RELEASE_THREAD_REPLIES__?: () => number;
|
|
}
|
|
).__BUZZ_E2E_RELEASE_THREAD_REPLIES__?.(),
|
|
);
|
|
await expect(headBody).toContainText("these PRs?");
|
|
await expect(headBody).not.toContainText("these two PRs?");
|
|
});
|