mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
## Summary Fixes the four desktop product races behind recurring E2E flakes in the 21–28 Oct 2026 CI audit. Each fix has a regression test: - In the channel browser, Enter acted on a stale deferred list. - A stale autocomplete swallowed the Enter that submits an empty edit. - Same-second messages swapped order on screen. - The unread pill read "Jump to latest" instead of "N new messages". Two E2E specs that failed for the same ordering reason also get test-side fixes. This is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found for these. Draft #8559 does not fix the voice-note flake. Draft #8661 (Refs #6733) closes the `#channel` list synchronously when an edit is cleared. §11 below also covers that case, plus the `@mention` and `:emoji` lists, so the two PRs overlap in `useChannelLinks.ts` and `empty-edit-delete.spec.ts`. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | §5 `channel-browser.spec.ts:402` "Enter with no matches jumps to create" | 15 of 50 main runs, e.g. 40597484657, 40582387515, 40566841699, 40367495044 | **Product.** The list renders from `useDeferredValue(query)`. An Enter that landed before the deferred render read the old list, so it opened a stale match or skipped the create row. | While the deferred query lags, Enter filters the live query with the same `selectBrowserChannels` the list renders from. | | §11 `empty-edit-delete.spec.ts:88`, `:111`, `:132` | :88 in 3 jobs, :111 in 3, :132 in 2. :111 failed all 3 attempts on main 39822883395 and 39799090750 | **Product.** Clearing the draft went through the 120 ms query debounce. The `#channel` list stayed open, so Enter re-inserted the suggestion instead of submitting the empty edit. | `useChannelLinks`, `useEmojiAutocomplete` and `useMentions` (typed `@` only) close as soon as the trigger is gone. Typing a query still debounces. `ChannelAutocomplete` gains `data-testid="channel-autocomplete"`. | | §10 `video-attachment.spec.ts:1469` "right-click menus expose distinct selectors…" | 10 of 50 main runs, e.g. 40528171327 | **Product.** The live cache merge (`sortMessages`, ascending id within a second) and the channel-window projection (descending id) disagreed, so two same-second rows swapped on screen as each writer landed. | A new `shared/lib/eventOrder.ts` holds `compareRelayOrder` and its exact reverse, `compareChronologicalOrder`. `sortMessages` and the window and reconciliation sorts all use the window's order, so older-page paging is unchanged. `sortEvents` keeps ascending id. The spec gives its three messages distinct seconds. | | §16 `voice-note.spec.ts:906` "starting a duplicate voice-note player pauses the other instance" | 6 of 50 main runs. All 3 attempts on PRs 39622488737 and 40309414135 | Same-second messages plus index-based card lookup. | Distinct seconds, and each card is found through its own message row. | | §13 `stream.spec.ts:477` "keeps scroll position when new messages arrive above the fold" (integration) | 7 of 50 main runs. All 3 attempts on PR 39546556833 | **Product.** Frozen rows came back reordered (7 of 12 instrumented runs). The selector ended older history at the snapshot's first id, so moved rows were both prepended and kept in the frozen tail. The duplicates filled the row count, `pendingCount` fell to 0, and the pill read "Jump to latest". Deleting a frozen row also dropped the whole freeze. | `useBufferedTimelineMessages` keys rows by `renderKey`, which is stable across an optimistic send and its ack (`frozenRowKeys`). It drops only deleted frozen rows, and ends older history at the first frozen row of any key. `stream.spec` is unchanged. | New regression tests: - `channel-browser.spec`: two forced Enter variants. - `empty-edit-delete.spec`: a `#`/`@`/`:` autocomplete table. - `autocompleteTriggerClose.jsdom-test`. - `projectChannelWindow.test`: the cache merge and the window agree in both arrival orders. - `channel-dense-second-reach.spec`: a RAF sampler sees one order. - `useBufferedTimelineMessages.test`: deletion, reorder and ack rows. - `virtualization.spec`: deleting a row while reading keeps the next arrival counted. - A `useThreadReplies.test` row: the thread fetch and a live append agree on same-second order. Local before/after counts are from the original `flakey-tests` commits (238c08c7c, c66f411f3): | Test | Before | After | |---|---|---| | channel-browser | 0/20 | 20/20 | | autocomplete table | 0/30 | 30/30 | | jsdom | 0/2 | 2/2 | | dense-second sampler | 0/10 | 10/10 | | buffered-timeline unit | 4/7 | 7/7 | | virtualization deletion | 0/10 | 10/10 | | `stream:477` against an isolated relay | 25/30, all failures "Jump to latest" | 30/30, then 20/20 and 10/10 | ### 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 and unit tests. - Every file except `empty-edit-delete.spec.ts` and `virtualization.spec.ts` is byte-identical to its final version on `flakey-tests` (67185df6a). Those two carry only this PR's hunks; see "Merge order". - Checked for this PR: `git diff --check`, biome on both partial specs (no changes, the same as on `main`), and a conflict-free merge with all six siblings onto `main`. ### Readiness These are **product fixes in the desktop app**. Under AGENTS.md "Before opening a PR", this needs an agent review, an agent run of the affected flows, and a **human in-app check** before `buzz-review-completed` is added. Suggested check, in `just dev` or a local build against any community: 1. **Channel browser:** open "Browse channels", type a name that matches nothing and press Enter straight away. The create-channel flow opens with that name. Then type an existing channel's exact name and press Enter straight away. That channel opens, not an earlier match. 2. **Empty-edit delete:** edit one of your own messages and type `#gen` so the channel list opens. Select all, delete, and press Enter at once. The empty edit is submitted (the delete-by-clearing flow) and no suggestion is re-inserted. Repeat with `@` (mention list) and `:sm` (emoji list). 3. **Same-second order:** send two short messages in quick succession, within one second. They appear in send order and never swap while they land. 4. **Unread pill:** in a busy channel, scroll up past the fold, and have a second client post one message. The pill reads "1 new message". While still scrolled up, delete one of your visible messages, then have the second client post again. The pill still counts the new message. ### Merge order No required order, but two files are shared: - `desktop/tests/e2e/empty-edit-delete.spec.ts` is also edited by **flakey-tests-desktop-e2e-actions**. This PR adds the autocomplete table after line 158. The sibling changes imports, helpers, `beforeEach` and line 143. - `desktop/tests/e2e/virtualization.spec.ts` is also edited by **flakey-tests-desktop-e2e-gates**. This PR adds one test after line 929. The sibling's hunks are all above line 551. The hunks don't touch, so each pair merges cleanly in either order, as checked with `git merge-tree`. Draft #8661 overlaps this PR in `useChannelLinks.ts` and `empty-edit-delete.spec.ts`; 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), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions). 🤖 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> Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
193 lines
7.7 KiB
TypeScript
193 lines
7.7 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
import { openGeneral, pressRowAction } from "../helpers/channelHeadReady";
|
|
import { clearComposer, replaceComposerText } from "../helpers/composer";
|
|
|
|
// A message the mock identity authors in #general. Editing/deleting one's own
|
|
// message is exactly Sam's workflow: "delete a message by clearing its edit."
|
|
// It is emitted per test rather than reusing the seeded `mock-general-welcome`:
|
|
// edits and deletions only overlay a target with a real 64-hex event id, so
|
|
// that seed keeps its edit only until the post-subscribe window refetch.
|
|
const ORIGINAL_CONTENT = "Ship notes for #general";
|
|
const RENDERED_ORIGINAL_CONTENT = "Ship notes for general";
|
|
let OWN_MESSAGE_ID: string;
|
|
|
|
// Open the more-actions menu for a message row and wait for the menu to mount.
|
|
async function openMoreActionsMenu(
|
|
page: import("@playwright/test").Page,
|
|
messageId: string,
|
|
) {
|
|
await pressRowAction(page.getByTestId(`more-actions-${messageId}`));
|
|
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
}
|
|
|
|
// Enter edit mode for a message, clear it to empty, and submit — the gesture
|
|
// that triggers the empty-edit delete confirmation.
|
|
async function submitEmptyEdit(
|
|
page: import("@playwright/test").Page,
|
|
messageId: string,
|
|
) {
|
|
await openMoreActionsMenu(page, messageId);
|
|
await page.getByTestId(`edit-message-${messageId}`).click();
|
|
await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 });
|
|
// Edit mode sets the editor content via Tiptap's async transaction pipeline;
|
|
// wait for it to populate before we clear it.
|
|
const input = page.getByTestId("message-input");
|
|
await expect(input).not.toBeEmpty({ timeout: 5_000 });
|
|
await clearComposer(input);
|
|
await page.keyboard.press("Enter");
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await openGeneral(page);
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
|
channelName: string;
|
|
kind: number;
|
|
exactChannel: boolean;
|
|
}) => boolean;
|
|
}
|
|
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
|
channelName: "general",
|
|
kind: 9,
|
|
exactChannel: true,
|
|
}) ?? false,
|
|
),
|
|
)
|
|
.toBe(true);
|
|
const emitted = await page.evaluate(
|
|
(content) =>
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
|
channelName: string;
|
|
content: string;
|
|
}) => { id: string } | undefined;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content }),
|
|
ORIGINAL_CONTENT,
|
|
);
|
|
if (!emitted?.id) throw new Error("mock message emit did not return an id");
|
|
OWN_MESSAGE_ID = emitted.id;
|
|
});
|
|
|
|
test("clearing an edit to empty prompts to delete, then deletes on confirm", async ({
|
|
page,
|
|
}) => {
|
|
const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
|
|
await submitEmptyEdit(page, OWN_MESSAGE_ID);
|
|
|
|
// The same "Delete message?" confirmation the Delete menu action shows — an
|
|
// empty edit is routed through it, not silently deleted.
|
|
const dialog = page.getByRole("alertdialog");
|
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
|
await expect(dialog).toContainText("Delete message?");
|
|
// Edit mode stays active while the dialog is open — it exits only on confirm.
|
|
await expect(page.getByTestId("edit-target")).toBeVisible();
|
|
|
|
// Confirm → the message row is removed and edit mode has exited.
|
|
await dialog.getByRole("button", { name: "Delete" }).click();
|
|
await expect(dialog).toBeHidden({ timeout: 5_000 });
|
|
await expect(page.getByTestId("edit-target")).toBeHidden();
|
|
await expect(row).toBeHidden({ timeout: 5_000 });
|
|
});
|
|
|
|
test("cancelling the empty-edit delete keeps the message", async ({ page }) => {
|
|
const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
|
|
await submitEmptyEdit(page, OWN_MESSAGE_ID);
|
|
|
|
const dialog = page.getByRole("alertdialog");
|
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Cancel → nothing is deleted, the original message survives, and the user is
|
|
// left in edit mode (the editing session is preserved, not discarded).
|
|
await dialog.getByRole("button", { name: "Cancel" }).click();
|
|
await expect(dialog).toBeHidden({ timeout: 5_000 });
|
|
await expect(page.getByTestId("edit-target")).toBeVisible();
|
|
await expect(row).toBeVisible();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
RENDERED_ORIGINAL_CONTENT,
|
|
);
|
|
await expect(row.getByLabel("Open channel general")).toBeVisible();
|
|
});
|
|
|
|
test("a non-empty edit still edits and never deletes", async ({ page }) => {
|
|
const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
|
|
await openMoreActionsMenu(page, OWN_MESSAGE_ID);
|
|
await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click();
|
|
await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 });
|
|
const input = page.getByTestId("message-input");
|
|
await expect(input).not.toBeEmpty({ timeout: 5_000 });
|
|
const editedContent = `Edited, not deleted ${Date.now()}`;
|
|
|
|
await replaceComposerText(input, editedContent);
|
|
await page.keyboard.press("Enter");
|
|
|
|
// No delete confirmation, edit mode exits, the row survives with new text.
|
|
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
|
await expect(page.getByTestId("edit-target")).toBeHidden({ timeout: 10_000 });
|
|
await expect(row).toBeVisible();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
editedContent,
|
|
);
|
|
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
|
RENDERED_ORIGINAL_CONTENT,
|
|
);
|
|
});
|
|
|
|
// Clearing a draft must close its autocomplete at once. An Enter inside the
|
|
// 120 ms debounce used to pick the stale suggestion instead of submitting, so
|
|
// the delete prompt never appeared. The draft's trailing `#general` opens the
|
|
// channel list on its own; the other triggers are typed after it.
|
|
for (const { trigger, typed, popover } of [
|
|
{ trigger: "#channel", typed: "", popover: "channel-autocomplete" },
|
|
{ trigger: "@mention", typed: " @al", popover: "mention-autocomplete" },
|
|
{ trigger: ":emoji", typed: " :smi", popover: "emoji-autocomplete" },
|
|
]) {
|
|
test(`an empty edit submits while the ${trigger} autocomplete is open`, async ({
|
|
page,
|
|
}) => {
|
|
const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
await openMoreActionsMenu(page, OWN_MESSAGE_ID);
|
|
await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click();
|
|
await expect(page.getByTestId("edit-target")).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
// The channel list opens only once the caret has landed after `#general`.
|
|
await expect(page.getByTestId("channel-autocomplete")).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
await page.keyboard.type(typed);
|
|
await expect(page.getByTestId(popover)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Clear and submit back-to-back, well inside the debounce.
|
|
await page.keyboard.press("ControlOrMeta+A");
|
|
await page.keyboard.press("Backspace");
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(page.getByRole("alertdialog")).toContainText(
|
|
"Delete message?",
|
|
{ timeout: 5_000 },
|
|
);
|
|
await expect(page.getByTestId("message-input")).toBeEmpty();
|
|
});
|
|
}
|