Files
buzz/desktop/tests/e2e/virtualization.spec.ts
Matt Toohey 1eb67bb525 fix(desktop): fix the product races behind four E2E flakes (#8680)
## 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>
2026-10-30 15:15:36 +11:00

1128 lines
43 KiB
TypeScript

import { expect, test } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import {
openGeneral,
waitForMockChannelHeadReady,
} from "../helpers/channelHeadReady";
const WATERCOOLER_CHANNEL_ID = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11";
const FORUM_THREAD_ID = "mock-forum-release-thread";
const FORUM_DEEPLINK_REPLY_ID = "mock-forum-release-deeplink";
// Mock-mode current-user pubkey (DEFAULT_MOCK_IDENTITY). Custom channel
// sections persist under buzz-channel-sections.v1:<pubkey>, so shot 6 seeds two
// sections for this key before the app boots.
const MOCK_PUBKEY = "deadbeef".repeat(8);
const SECTION_TOP = { id: "sec-top", name: "Priority", order: 0 };
const SECTION_BOTTOM = { id: "sec-bottom", name: "Archive", order: 1 };
async function seedChannelSections(page: Page) {
await page.addInitScript(
({ pubkey, sections }) => {
window.localStorage.setItem(
`buzz-channel-sections.v1:${pubkey}`,
JSON.stringify({ version: 1, sections, assignments: {} }),
);
},
{ pubkey: MOCK_PUBKEY, sections: [SECTION_TOP, SECTION_BOTTOM] },
);
}
// dnd-kit's PointerSensor activates only after the pointer travels past its
// 6px distance constraint, so a single move never starts a drag. This walks the
// pointer down, past the activation threshold, onto the target, then releases —
// the sequence dnd-kit needs to fire onDragEnd and commit the reorder.
async function dragOver(
page: Page,
source: Locator,
target: Locator,
targetSectionId: string,
) {
const from = await source.boundingBox();
if (!from) throw new Error("drag source not laid out");
const pointer = {
x: from.x + from.width / 2,
y: from.y + from.height / 2,
};
await source.dispatchEvent("pointerdown", {
button: 0,
buttons: 1,
clientX: pointer.x,
clientY: pointer.y,
isPrimary: true,
pointerId: 1,
pointerType: "mouse",
});
await page.evaluate(({ x, y }) => {
document.dispatchEvent(
new PointerEvent("pointermove", {
bubbles: true,
buttons: 1,
clientX: x,
clientY: y + 8,
isPrimary: true,
pointerId: 1,
pointerType: "mouse",
}),
);
}, pointer);
await expect(page.getByTestId("sidebar-section-drag-overlay")).toBeVisible();
const to = await target.boundingBox();
if (!to) throw new Error("drag target not laid out");
// Aim at the target's centre, well inside its droppable rect, not 2 px
// above its bottom edge.
const destination = {
x: to.x + to.width / 2,
y: to.y + to.height / 2,
};
await page.evaluate(({ x, y }) => {
document.dispatchEvent(
new PointerEvent("pointermove", {
bubbles: true,
buttons: 1,
clientX: x,
clientY: y,
isPrimary: true,
pointerId: 1,
pointerType: "mouse",
}),
);
}, destination);
// dnd-kit announces each new drop target in its live region. Release only
// after it names the target (its sortable row or its body), rather than
// after a fixed number of frames, so the drop cannot race the collision.
await expect(
page.locator('[id^="DndLiveRegion-"]').filter({
hasText: new RegExp(
`moved over droppable area (section-drop:)?${targetSectionId}\\.`,
),
}),
).toHaveCount(1);
await page.evaluate(({ x, y }) => {
document.dispatchEvent(
new PointerEvent("pointerup", {
bubbles: true,
button: 0,
buttons: 0,
clientX: x,
clientY: y,
isPrimary: true,
pointerId: 1,
pointerType: "mouse",
}),
);
}, destination);
}
test.describe("list virtualization", () => {
test("01 — Pulse windowed feed with sticky composer pinned mid-scroll", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("open-pulse-view").click();
// The seeded feed overflows the viewport (30 notes), so the windowed list
// renders a subset and the composer stays pinned. Wait for virtual rows.
const rows = page.locator("[data-index]");
await expect(rows.first()).toBeVisible();
const composer = page.locator(".pulse-composer");
await expect(composer).toBeVisible();
// Scroll the feed mid-list, then prove the sticky composer is still pinned
// at the top of its scroll container — this exercises the
// translateY(start - scrollMargin) offset under a non-zero scrollTop.
const scroller = composer.locator(
"xpath=ancestor::*[contains(@class,'overflow-y-auto')][1]",
);
await scroller.evaluate((el) => {
el.scrollTop = 600;
});
await expect
.poll(async () =>
composer.evaluate(
(el, scrollEl) => {
const composerTop = el.getBoundingClientRect().top;
const scrollTop = (scrollEl as HTMLElement).getBoundingClientRect()
.top;
return Math.abs(composerTop - scrollTop);
},
await scroller.elementHandle(),
),
)
.toBeLessThan(80);
});
test("02 — forum deep-link lands on an offscreen reply", async ({ page }) => {
await installMockBridge(page);
await page.goto(
`/#/channels/${WATERCOOLER_CHANNEL_ID}/posts/${FORUM_THREAD_ID}?replyId=${FORUM_DEEPLINK_REPLY_ID}`,
);
// The deep-link target is the last of 25 replies — offscreen at open. Under
// content-visibility the row stays queryable, so scrollIntoView lands it.
const target = page.locator(
`[data-forum-event-id="${FORUM_DEEPLINK_REPLY_ID}"]`,
);
await expect(target).toBeVisible();
await expect(target).toContainText("Deep-link target");
// Assert the row sits within the viewport vertically — proves the scroll
// actually moved to it rather than leaving it below the fold.
await expect
.poll(async () =>
target.evaluate((el) => {
const rect = el.getBoundingClientRect();
return rect.top >= 0 && rect.bottom <= window.innerHeight;
}),
)
.toBe(true);
});
test("03 — members search shows both sticky titles under content-visibility", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey:
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea",
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
// "a" matches member `alice` (Members section) and non-member `charlie`
// (Not in this channel section) — both heterogeneous lists + both sticky
// titles must stay alive under content-visibility.
await page.getByTestId("channel-management-search-users").fill("a");
await expect(page.getByText("Members", { exact: true })).toBeVisible();
await expect(
page.getByText("Not in this channel", { exact: true }),
).toBeVisible();
// A member row and an add-search (non-member) row both rendered.
await expect(
page.getByTestId("members-sidebar-people").getByText("alice"),
).toBeVisible();
await expect(
page.locator('[data-testid^="channel-user-search-result-"]').first(),
).toBeVisible();
});
test("06 — custom-section dnd reorder commits under content-visibility", async ({
page,
}) => {
await seedChannelSections(page);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// dnd-kit marks each section's wrapping row with role="button" +
// aria-roledescription="sortable" and spreads the drag listeners there, so
// the row itself is the handle. Scoping to that attribute reads the live
// section order and excludes the inner disclosure button and the (hidden)
// assign-to-section context-menu items that reuse the same names.
const headers = page.locator('[aria-roledescription="sortable"]');
const topHeader = headers.filter({ hasText: "Priority" });
const bottomHeader = headers.filter({ hasText: "Archive" });
const topHeaderButton = topHeader.getByRole("button", {
name: "Priority",
exact: true,
});
await expect(topHeader).toBeVisible();
await expect(bottomHeader).toBeVisible();
await expect(headers).toHaveCount(2);
const sectionOrder = async () =>
headers.evaluateAll((rows) =>
rows.map((row) =>
row.textContent?.trim().startsWith("Priority")
? "Priority"
: "Archive",
),
);
expect(await sectionOrder()).toEqual(["Priority", "Archive"]);
// Drag "Priority" past "Archive" — onDragEnd commits arrayMove and persists
// the new order. The drop must land for the order to flip.
await dragOver(page, topHeaderButton, bottomHeader, SECTION_BOTTOM.id);
// The drop landed: order flipped. A no-op drag would leave it unchanged.
await expect.poll(sectionOrder).toEqual(["Archive", "Priority"]);
// Both section rows stayed committed in the DOM across the reorder — the
// content-visibility invariant the divergence rests on (no unmount).
await expect(headers).toHaveCount(2);
});
test("08 — cascading older pages never snap the viewport toward newest", async ({
page,
}) => {
test.setTimeout(120_000);
// A real CDP wheel burst is required here: assigning scrollTop does not
// reproduce Chromium/WebKit's native wheel → scroll callback ordering. The
// old boundary rollback moved the viewport back down before the fetch
// committed; keep that pre-prepend reversal below the same 5px frame bar.
// Each older page stays in flight until the burst has been traced, so the
// input boundary and prepend commit are two distinct phases and this
// assertion cannot accidentally measure only the later anchor correction.
await installMockBridge(page, { deepHistoryMessageCount: 1_800 });
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Initial bottom positioning can momentarily cross the start threshold. Let
// any resulting page transaction settle before driving explicit crossings.
await page.waitForTimeout(1_000);
const sampleVisibleAnchor = (expectedId?: string) =>
timeline.evaluate(async (scroller, anchorId) => {
const s = scroller as HTMLElement;
for (let frame = 0; frame < 60; frame += 1) {
const scrollerTop = s.getBoundingClientRect().top;
const rows = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const row = anchorId
? rows.find((candidate) => candidate.dataset.messageId === anchorId)
: rows.find(
(candidate) =>
candidate.getBoundingClientRect().top >= scrollerTop,
);
if (row) {
return {
id: row.dataset.messageId ?? "",
top: row.getBoundingClientRect().top - scrollerTop,
scrollHeight: s.scrollHeight,
bottomDistance: s.scrollHeight - s.clientHeight - s.scrollTop,
};
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
throw new Error(
`anchor row ${anchorId ?? "at viewport top"} not mounted`,
);
}, expectedId);
// Load fifteen consecutive server pages in one mounted virtualizer. This
// is the production shape that exposed the intermittent end-cache snap:
// variable-height rows and repeated front insertions exercise the full
// index-shift path rather than allowing a single lucky pass.
for (let pageIndex = 0; pageIndex < 15; pageIndex += 1) {
// Leave the threshold first so Virtua emits a fresh start-edge crossing;
// initial positioning can briefly report offset 0 while mounting.
await timeline.evaluate((element) => {
element.scrollTop = 4000;
});
await page.waitForTimeout(300);
// Hold the page this crossing fetches. A slow runner can pause between
// wheels for longer than the settle gate's quiet window, and the gate
// would then rightly commit a page that had already arrived mid-burst.
await page.evaluate(() =>
window.__BUZZ_E2E_HOLD_CHANNEL_WINDOW_PAGES__?.(true),
);
await timeline.evaluate((element) => {
element.scrollTop = 180;
});
await page.waitForTimeout(150);
const before = await sampleVisibleAnchor();
const ctrlWheelPromise =
pageIndex === 0
? timeline.evaluate(
(scroller) =>
new Promise<boolean>((resolve) => {
const s = scroller as HTMLElement;
const observer = new MutationObserver(() => {
observer.disconnect();
// Ctrl+wheel is browser zoom, not reader scroll intent. Fire
// it synchronously with the prepend DOM commit, before the
// ResizeObserver measurement batch reconciles estimated rows.
s.dispatchEvent(
new WheelEvent("wheel", { ctrlKey: true, deltaY: -100 }),
);
resolve(true);
});
observer.observe(s.firstElementChild ?? s, {
childList: true,
subtree: true,
});
}),
)
: Promise.resolve(false);
// CDP can deliver a wheel burst late (Linux Chromium more than macOS),
// so a window timed from before the burst can close before the input
// lands. Time the 120 ms window from the first wheel the page receives
// and keep it open until every wheel has arrived. The held page cannot
// commit meanwhile, so no anchor correction is read as a rollback.
const upDeltas = [-60, -30, -20, -15];
const wheelTracePromise = timeline.evaluate(
async (scroller, wheelCount) => {
const s = scroller as HTMLElement;
let received = 0;
const countWheel = (event: WheelEvent) => {
if (!event.ctrlKey) received += 1;
};
s.addEventListener("wheel", countWheel, { passive: true });
let previousScrollTop = s.scrollTop;
let maxBoundaryRollback = 0;
let minScrollTop = s.scrollTop;
let firstWheelAt: number | null = null;
const giveUpAt = performance.now() + 5_000;
while (performance.now() < giveUpAt) {
if (firstWheelAt === null && received > 0) {
firstWheelAt = performance.now();
}
maxBoundaryRollback = Math.max(
maxBoundaryRollback,
s.scrollTop - previousScrollTop,
);
previousScrollTop = s.scrollTop;
minScrollTop = Math.min(minScrollTop, s.scrollTop);
if (
received >= wheelCount &&
firstWheelAt !== null &&
performance.now() - firstWheelAt >= 120
) {
break;
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
s.removeEventListener("wheel", countWheel);
return { maxBoundaryRollback, minScrollTop, received };
},
upDeltas.length,
);
const box = await timeline.boundingBox();
if (!box) throw new Error("timeline has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
for (const deltaY of upDeltas) {
await page.mouse.wheel(0, deltaY);
await page.waitForTimeout(12);
}
const wheelTrace = await wheelTracePromise;
expect(wheelTrace.received).toBe(upDeltas.length);
expect(wheelTrace.minScrollTop).toBeLessThanOrEqual(350);
expect(wheelTrace.maxBoundaryRollback).toBeLessThan(5);
// A late final delta lands AFTER an anchor baseline taken during a
// pause in the burst, and maxDrift then reports the reader's own wheel
// as anchor drift (it once read exactly 15, the -15 delta). wheelTrace
// returned only after every wheel arrived; now wait for the scroll they
// caused to hold still for three frames. This tightens the assertion
// rather than diluting it — the baseline becomes honest and genuine
// post-prepend drift still reads as drift.
await timeline.evaluate(async (element) => {
let prior = element.scrollTop;
let stillFrames = 0;
for (let frame = 0; frame < 60 && stillFrames < 3; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
stillFrames = element.scrollTop === prior ? stillFrames + 1 : 0;
prior = element.scrollTop;
}
});
const committedAnchor = await sampleVisibleAnchor(before.id);
const motion = await timeline.evaluate(
async (scroller, { anchorId, anchorTop, oldHeight }) => {
const s = scroller as HTMLElement;
// Let the held page go only once this trace is running, so it sees
// every frame of the commit against a baseline taken before it.
const released =
window.__BUZZ_E2E_HOLD_CHANNEL_WINDOW_PAGES__?.(false) ?? 0;
let maxDrift = 0;
let sawPrepend = false;
let sawAnchorAfterPrepend = false;
let finalDrift = 0;
let stableFrames = 0;
for (let frame = 0; frame < 180; frame += 1) {
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((candidate) => candidate.dataset.messageId === anchorId);
if (s.scrollHeight > oldHeight + 800 && !sawPrepend) {
sawPrepend = true;
}
if (row) {
const top =
row.getBoundingClientRect().top - s.getBoundingClientRect().top;
const drift = Math.abs(top - anchorTop);
if (sawPrepend) {
maxDrift = Math.max(maxDrift, drift);
sawAnchorAfterPrepend = true;
stableFrames =
Math.abs(drift - finalDrift) < 0.5 ? stableFrames + 1 : 0;
finalDrift = drift;
}
}
if (sawAnchorAfterPrepend && stableFrames >= 8) break;
await new Promise((resolve) => requestAnimationFrame(resolve));
}
return { maxDrift, released, sawPrepend };
},
{
anchorId: committedAnchor.id,
anchorTop: committedAnchor.top,
oldHeight: before.scrollHeight,
},
);
expect(motion.released).toBe(1);
expect(motion.sawPrepend).toBe(true);
if (pageIndex === 0) expect(await ctrlWheelPromise).toBe(true);
expect(motion.maxDrift).toBeLessThan(5);
await expect
.poll(
async () => timeline.evaluate((element) => element.scrollHeight),
{
timeout: 10_000,
},
)
.toBeGreaterThan(before.scrollHeight + 800);
// A snap to newest leaves this near zero. Keep a full viewport of history
// below the reader after every prepend, rather than checking only the
// final page and missing a transient cascade failure.
const bottomDistance = await timeline.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
);
expect(bottomDistance).toBeGreaterThan(
await timeline.evaluate((element) => element.clientHeight),
);
// Leave the boundary with real downward wheel input after this prepend.
// That reader intent retires Virtua's active prepend reconciliation, so
// later row measurements must not pull the viewport back toward the
// completed prepend before the next upward load.
// Same late-delivery guard as the upward burst: time the 400 ms window
// from the first wheel received, and close it only once every wheel has
// arrived and the scroll has held still for two frames.
const downDeltas = [120, 100, 80];
const exitTracePromise = timeline.evaluate(
async (scroller, wheelCount) => {
const s = scroller as HTMLElement;
let received = 0;
const countWheel = (event: WheelEvent) => {
if (!event.ctrlKey) received += 1;
};
s.addEventListener("wheel", countWheel, { passive: true });
const startScrollTop = s.scrollTop;
let previousScrollTop = startScrollTop;
let maxForwardTravel = 0;
let maxRollback = 0;
let firstWheelAt: number | null = null;
let stillFrames = 0;
const giveUpAt = performance.now() + 5_000;
while (performance.now() < giveUpAt) {
if (firstWheelAt === null && received > 0) {
firstWheelAt = performance.now();
}
const travel = s.scrollTop - startScrollTop;
maxForwardTravel = Math.max(maxForwardTravel, travel);
maxRollback = Math.max(
maxRollback,
previousScrollTop - s.scrollTop,
);
stillFrames =
s.scrollTop === previousScrollTop ? stillFrames + 1 : 0;
previousScrollTop = s.scrollTop;
if (
received >= wheelCount &&
firstWheelAt !== null &&
performance.now() - firstWheelAt >= 400 &&
stillFrames >= 2
) {
break;
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
s.removeEventListener("wheel", countWheel);
return { maxForwardTravel, maxRollback, received };
},
downDeltas.length,
);
const exitBox = await timeline.boundingBox();
if (!exitBox) throw new Error("timeline has no bounding box");
await page.mouse.move(
exitBox.x + exitBox.width / 2,
exitBox.y + exitBox.height / 2,
);
for (const deltaY of downDeltas) {
await page.mouse.wheel(0, deltaY);
await page.waitForTimeout(12);
}
const exitTrace = await exitTracePromise;
expect(exitTrace.received).toBe(downDeltas.length);
expect(exitTrace.maxForwardTravel).toBeGreaterThan(200);
expect(exitTrace.maxRollback).toBeLessThan(5);
// Loading more history must not return keepMounted to its old linear
// growth. Virtua still retains every measured size for spacer geometry;
// only the live message-row DOM stays bounded around the reader and tail.
const mountedMessageCount = await timeline
.locator("[data-message-id]")
.count();
expect(mountedMessageCount).toBeLessThan(400);
}
});
test("09 — older-page render commit waits for scroller rest under continued wheel input", async ({
page,
}) => {
test.setTimeout(60_000);
// Production shape for the WKWebView dropped-write hazard: heavy
// variable-height rows, a slow older-page fetch, and wheel input that
// KEEPS ARRIVING through fetch resolution. Every prepend-compensation
// mechanism is a scrollTop write, and macOS WebKit can drop those writes
// while trackpad momentum owns the offset — so the contract under test is
// that the fetched page's RENDER COMMIT (the scrollHeight jump) is
// deferred until input quiesces, and that the at-rest commit then holds
// the anchored row. Chromium cannot reproduce the dropped write itself;
// it CAN prove the commit-at-rest scheduling that makes it unreachable.
await installMockBridge(page, {
deepHistoryMessageCount: 1_800,
channelWindowDelayMs: 300,
});
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForTimeout(1_000);
// Mount mid-history rows clear of the load-older sentinel, then trip it.
await timeline.evaluate((element) => {
element.scrollTop = 4000;
});
await page.waitForTimeout(300);
// In-page observer: tracks the last wheel-input timestamp, captures the
// first at-rest anchor after input stops, and records when the prepend
// commit (scrollHeight jump) lands relative to the last input.
const tracePromise = timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
const baseHeight = s.scrollHeight;
let lastInputTs = 0;
let sawInput = false;
let restAnchor: { id: string; top: number } | null = null;
const onWheel = () => {
lastInputTs = performance.now();
sawInput = true;
// Input after a lull invalidates any anchor captured during it —
// the commit must be measured against the FINAL at-rest position.
restAnchor = null;
};
s.addEventListener("wheel", onWheel, { passive: true });
let commit: { ts: number; gapSinceInput: number } | null = null;
let sawSpinnerDuringHold = false;
let anchorDriftAfterCommit: number | null = null;
const deadline = performance.now() + 8_000;
while (performance.now() < deadline) {
const now = performance.now();
if (commit === null) {
if (
document.querySelector(
'[data-testid="message-timeline-fetching-older"]',
) !== null
) {
sawSpinnerDuringHold = true;
}
// First frame at rest (input quiet for 60ms — shorter than the
// gate's own window, so this reading always precedes admission):
// capture the row the at-rest commit must hold.
if (restAnchor === null && sawInput && now - lastInputTs >= 60) {
const scrollerTop = s.getBoundingClientRect().top;
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find(
(candidate) =>
candidate.getBoundingClientRect().top - scrollerTop >= 0,
);
if (row?.dataset.messageId) {
restAnchor = {
id: row.dataset.messageId,
top: row.getBoundingClientRect().top - scrollerTop,
};
}
}
if (s.scrollHeight > baseHeight + 800) {
commit = { ts: now, gapSinceInput: now - lastInputTs };
}
} else if (restAnchor !== null) {
const anchor = restAnchor;
const scrollerTop = s.getBoundingClientRect().top;
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((candidate) => candidate.dataset.messageId === anchor.id);
if (row) {
anchorDriftAfterCommit = Math.max(
anchorDriftAfterCommit ?? 0,
Math.abs(
row.getBoundingClientRect().top - scrollerTop - anchor.top,
),
);
}
// Watch a settle window after the commit, then finish.
if (now - commit.ts > 700) break;
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
s.removeEventListener("wheel", onWheel);
return {
commit,
capturedRestAnchor: restAnchor !== null,
sawSpinnerDuringHold,
anchorDriftAfterCommit,
};
});
// Trip the boundary, then keep real wheel input flowing DOWN (away from
// the boundary) through and well past the 300ms fetch resolution — the
// mid-gesture window in which the ungated build commits the page.
await timeline.evaluate((element) => {
element.scrollTop = 150;
});
const box = await timeline.boundingBox();
if (!box) throw new Error("timeline has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
for (let burst = 0; burst < 30; burst += 1) {
await page.mouse.wheel(0, 30);
await page.waitForTimeout(40);
}
const trace = await tracePromise;
// The page must eventually commit — the gate defers, never strands.
expect(trace.commit).not.toBeNull();
// The commit landed only after input quiesced. On the ungated build the
// deferred snapshot flushes as soon as the fetch resolves — between wheel
// bursts, a gap far below the quiet window — so this line is the red/green
// signal for the settle gate.
expect(trace.commit?.gapSinceInput ?? 0).toBeGreaterThanOrEqual(80);
// The reader saw the fetching affordance while the page was held.
expect(trace.sawSpinnerDuringHold).toBe(true);
// The at-rest commit held the anchored row (writes land at rest).
expect(trace.capturedRestAnchor).toBe(true);
expect(trace.anchorDriftAfterCommit ?? 0).toBeLessThan(5);
});
});
test("thread-heavy history mounts every loaded row", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Seed summaries on 120 loaded roots. Every loaded row should be realized
// immediately so first-pass scrolling never encounters Virtua's hidden
// pre-measurement state.
await page.evaluate(() => {
for (let index = 480; index < 600; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "deep-history",
content: `summary-only reply ${index}`,
parentEventId: `mock-deep-history-${index}`,
});
}
});
await page.getByTestId("channel-deep-history").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Emit after opening so the live summary path updates every loaded root,
// independent of the relay page's summary cap.
await page.evaluate(() => {
for (let index = 480; index < 600; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "deep-history",
content: `live summary-only reply ${index}`,
parentEventId: `mock-deep-history-${index}`,
});
}
});
await page.waitForTimeout(500);
await page.waitForTimeout(300);
const loadedRows = timeline.locator("[data-message-id]");
// The mock channel's current loaded window contains 50 roots; all of them
// must already exist and be painted before the first scroll gesture.
await expect(loadedRows).toHaveCount(50);
expect(
await loadedRows.evaluateAll((rows) =>
rows.every((row) => getComputedStyle(row).visibility === "visible"),
),
).toBe(true);
});
test("channel switches settle the last row above the composer", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (const [channelName, prefix] of [
["general", "switch-general"],
["engineering", "switch-engineering"],
] as const) {
for (let index = 0; index < 60; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: `${prefix}-${index}`,
createdAt: 1_700_000_000 + index,
});
}
}
});
for (const channelName of ["general", "engineering", "general"]) {
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
const timeline = page.getByTestId("message-timeline");
const composer = page.getByTestId("channel-composer-overlay");
await expect
.poll(async () =>
timeline.evaluate(
(element, composerElement) => {
const rows = Array.from(
element.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const lastRow = rows.at(-1);
if (!lastRow) return Number.POSITIVE_INFINITY;
return (
lastRow.getBoundingClientRect().bottom -
(composerElement as HTMLElement).getBoundingClientRect().top
);
},
await composer.elementHandle(),
),
)
.toBeLessThanOrEqual(1);
}
});
test("offscreen rich-row resize preserves the viewport-center anchor", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (let index = 0; index < 240; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: [
`rich resize row ${index}`,
"long wrapped text ".repeat((index % 8) + 1),
].join("\n"),
createdAt: 1_700_700_000 + index,
});
}
});
await page.getByTestId("channel-general").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
const result = await timeline.evaluate(async (element) => {
const scroller = element as HTMLDivElement;
// Retire bottom-follow intent the same way real reader input does before
// moving into detached history. A raw scrollTop assignment alone is not
// user intent and would correctly leave bottom following armed.
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
scroller.scrollTop = scroller.scrollHeight / 2;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 250));
const scrollerRect = scroller.getBoundingClientRect();
const rows = Array.from(
scroller.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const visibleRows = rows.filter((row) => {
const rect = row.getBoundingClientRect();
return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom;
});
const viewportCenter = scrollerRect.top + scroller.clientHeight / 2;
const anchor = visibleRows.reduce<HTMLElement | null>((nearest, row) => {
if (!nearest) return row;
const rowRect = row.getBoundingClientRect();
const nearestRect = nearest.getBoundingClientRect();
const rowDistance = Math.abs(
(rowRect.top + rowRect.bottom) / 2 - viewportCenter,
);
const nearestDistance = Math.abs(
(nearestRect.top + nearestRect.bottom) / 2 - viewportCenter,
);
return rowDistance < nearestDistance ? row : nearest;
}, null);
if (!anchor) throw new Error("no visible center anchor");
const rowAbove = rows
.filter((row) => row.getBoundingClientRect().bottom <= scrollerRect.top)
.at(-1);
if (!rowAbove) throw new Error("no mounted offscreen rich row above");
const anchorRect = anchor.getBoundingClientRect();
const anchorCenterOffset =
(anchorRect.top + anchorRect.bottom) / 2 - viewportCenter;
const scrollTop = scroller.scrollTop;
rowAbove.style.minHeight = `${rowAbove.getBoundingClientRect().height + 240}px`;
await new Promise((resolve) => setTimeout(resolve, 500));
const nextScrollerRect = scroller.getBoundingClientRect();
const nextAnchorRect = anchor.getBoundingClientRect();
const nextViewportCenter = nextScrollerRect.top + scroller.clientHeight / 2;
return {
anchorDrift:
(nextAnchorRect.top + nextAnchorRect.bottom) / 2 -
nextViewportCenter -
anchorCenterOffset,
scrollCorrection: scroller.scrollTop - scrollTop,
};
});
expect(Math.abs(result.anchorDrift)).toBeLessThanOrEqual(2);
expect(result.scrollCorrection).toBeGreaterThanOrEqual(238);
});
test("live tail arrivals keep a bottom-pinned virtual timeline settled", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (let index = 0; index < 60; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `live-follow seed ${index}\nsecond line ${index}`,
createdAt: 1_700_000_000 + index,
});
}
});
await page.getByTestId("channel-general").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("live-follow seed 59");
await expect
.poll(() =>
timeline.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
),
)
.toBeLessThanOrEqual(1);
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content:
"remote live-follow sentinel\nline two\nline three\nline four\nline five",
createdAt: 1_800_000_000,
});
});
await expect(page.getByText("remote live-follow sentinel")).toBeVisible();
await expect
.poll(() =>
timeline.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
),
)
.toBeLessThanOrEqual(1);
});
test("live tail arrivals stay buffered while reading and release on jump", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.getByTestId("channel-deep-history").click();
await waitForMockChannelHeadReady(
page,
"deep-history",
"feedf00d-0000-4000-8000-000000000007",
);
await page.evaluate(() => document.fonts.ready.then(() => undefined));
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await timeline.evaluate((element) => {
// Reader input retires the virtualizer's bottom-follow intent before
// moving into history; a scrollTop write alone leaves it armed.
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
element.scrollTop = Math.max(500, element.scrollHeight / 2);
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
const frozenHeight = await timeline.evaluate(async (element) => {
// Entering history renders and measures new virtual rows. Capture the
// baseline only after that layout has stopped changing across frames.
let previousHeight = element.scrollHeight;
let previousOffset = element.scrollTop;
let stableFrames = 0;
for (let frame = 0; frame < 120; frame += 1) {
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
const height = element.scrollHeight;
const offset = element.scrollTop;
stableFrames =
height === previousHeight && offset === previousOffset
? stableFrames + 1
: 0;
if (stableFrames >= 3) return height;
previousHeight = height;
previousOffset = offset;
}
throw new Error(
"virtual timeline layout did not settle before live arrival",
);
});
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "deep-history",
content: "buffered live tail sentinel",
createdAt: 1_900_000_000,
});
});
await expect(page.getByText("buffered live tail sentinel")).toHaveCount(0);
await expect(page.getByTestId("message-scroll-to-latest")).toContainText("1");
expect(await timeline.evaluate((element) => element.scrollHeight)).toBe(
frozenHeight,
);
await page.getByTestId("message-scroll-to-latest").click();
await expect(page.getByText("buffered live tail sentinel")).toBeVisible();
});
test("deleting a row while reading keeps live arrivals counted", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
const newestSeedId = await page.evaluate(() => {
let newest = "";
for (let index = 0; index < 20; index += 1) {
newest =
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `delete-while-reading seed ${index}\nsecond line ${index}`,
createdAt: 1_700_000_000 + index,
})?.id ?? "";
}
return newest;
});
// The deletion and the arrival below are live events, so live admission and
// its head refresh must land before reading starts.
await openGeneral(page);
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("delete-while-reading seed 19");
await timeline.evaluate((element) => {
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
element.scrollTop -= 300;
});
const pill = page.getByTestId("message-scroll-to-latest");
await expect(pill).toBeVisible();
// Removing a frozen row used to drop the whole freeze, so the next arrival
// joined the tail uncounted and the pill read "Jump to latest".
await page.evaluate((target) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: "",
kind: 5,
extraTags: [["e", target]],
});
}, newestSeedId);
await expect(page.locator(`[data-message-id="${newestSeedId}"]`)).toHaveCount(
0,
);
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: "delete-while-reading live sentinel",
createdAt: 1_800_000_000,
});
});
await expect(pill).toContainText("1 new message");
await expect(
page.getByText("delete-while-reading live sentinel"),
).toHaveCount(0);
await pill.click();
await expect(
page.getByText("delete-while-reading live sentinel"),
).toBeVisible();
});
test("a single scroll back to the bottom releases the live tail", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (let index = 0; index < 20; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `single-return seed ${index}\nsecond line ${index}`,
createdAt: 1_700_000_000 + index,
});
}
});
await page.getByTestId("channel-general").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("single-return seed 19");
const distanceFromBottom = () =>
timeline.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
);
await expect.poll(distanceFromBottom).toBeLessThanOrEqual(1);
// Leave the bottom, then come back in one native scroll event, as a
// scrollbar jump, End key, or Playwright's scroll-into-view retry does.
await timeline.evaluate((element) => {
element.scrollTop -= 150;
});
await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
await timeline.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect(page.getByTestId("message-scroll-to-latest")).toHaveCount(0);
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: "single-return live sentinel",
createdAt: 1_800_000_000,
});
});
await expect(page.getByText("single-return live sentinel")).toBeVisible();
await expect(page.getByTestId("message-scroll-to-latest")).toHaveCount(0);
});