Files
buzz/desktop/tests/e2e/virtualization.spec.ts
Matt Toohey 0052b2354e test(desktop): gate flaky E2E specs on the state they assume (#8681)
## Summary

Test-side fixes for ten desktop E2E flake clusters and six long-tail
rows from the 21–28 Oct 2026 CI audit. Each spec now waits for the state
it assumes (a rendered row, a mounted listener, a finished animation, a
held boot read), not a fixed delay or a one-shot probe. Two mock-bridge
seams support this.

The PR changes test code and mock-bridge seams only, apart from one
product constant extracted with its value unchanged. It is one of seven
PRs split out of the `flakey-tests` branch (siblings below).

### Related issue

None found.

- #8622, #8244 and #8386 touch some of these spec files, but not the
tests fixed here.
- §7 mode A (`navigation:472`) is left to open PR #6852, which already
sets the width and measures in one polled evaluate.
- Draft #8279 queues cold-launch nostr-bind links in the product. Its
mock returns null, so the §12 listener seam is still needed.

### Mock-bridge seams

- `__BUZZ_E2E_EVENT_LISTENER_COUNT__(event)` counts live **app**
listeners per Tauri event. It wraps the mocked
`plugin:event|listen`/`unlisten`. The spec-facing
`__TAURI_INTERNALS__.listen` passes a marker target that the wrapper
skips, so a spec's own subscription can't satisfy the wait. Tauri
2.11.1's mock ignores targets, so delivery is unchanged.
- `deferFirstChannelsRead` arms the existing channels-read hold at boot.
`__BUZZ_E2E_RELEASE_CHANNELS_READ__` releases it.
- `CREATE_PROJECT_REFOCUS_DELAY_MS` moves to `createProjectFormFocus.ts`
with its value unchanged (50 ms). Both `CreateProjectFormContent` and
`project-commit-detail.spec` import it.

### Flaky tests fixed

| Test | Failing runs | Cause | Change |
|---|---|---|---|
| §2 `overscroll-boundary.spec.ts:34` (and its sibling) | 39601763602,
40367495044, 40583830861, 40597484657, 40373404267 | The
`message-timeline` test id first lands on the loading skeleton, which
doesn't overflow, so the scroll lock rightly cancels the wheel. | Wait
for a rendered row, and content taller than the viewport, before
dispatching wheels. |
| §4 `project-conversation-load-failure.spec.ts:73` | 40367495044 (all 3
attempts), 40608383847, 40583830861; PRs 39541263507, 40509460168 | The
seeded root and reply were dated `now`. The panel's live REQ (`since =
floor(now)`) still replayed the reply, so the error card never appeared.
| Date both events a minute in the past, and correct the comment. |
| §6 `workflows.spec.ts` `createWorkflow` cluster (:222, :365, :297,
:525, :471, :406, :614, :997) | 14 of 50 main jobs; :222 ×4, e.g.
40353619362 | `AnimatePresence mode="wait"` keeps the trigger body
mounted through its exit, so "Message text" could fill the trigger's
field (:222 and the callers). For :297, `isVisible()` read "closed" one
frame before the dialog opened its own channel list. | Wait for the step
body's "Step details" control before filling. Create dialogs wait for
the list; only the duplicate dialog clicks it. |
| §12 `nostr-bind.spec.ts` (:156, :198, :228, :247, :260, :335, :417,
:447) | 8 main jobs, each test once | The spec emitted
`deep-link-nostr-bind` before the app subscribed, and the mock drops
events that have no listener. | Wait for
`__BUZZ_E2E_EVENT_LISTENER_COUNT__` to report the app listener before
emitting. |
| §15 `profile.spec.ts:1128` (integration) | 7 of 50 main jobs | The 320
ms motion-sampling window started before the click. | Start the window
when `activeTab` reads "runtime". |
| §17 `agent-availability.spec.ts:602`, both shutdown-failure variants
(integration) | 6 jobs (4 disconnected-online, 2 failed-online) | The
"shutdown refused" toast sits over the Delete row and pauses while the
pointer is on it, so the retry click is intercepted. | Assert the toast,
then retry with focus + Enter. The toaster has no close button. |
| §19 `virtualization.spec.ts:247` "08 — cascading older pages…" | 4 of
50 main jobs; PR 40605736093 (all 3 attempts) | The 120 ms and 400 ms
windows were timed from before the wheel bursts, and CDP can deliver
wheels late. | Count the wheels the page receives. Each window opens at
the first wheel and closes only after all have landed, then waits for
still frames. **A second, product-side mode remains** (see below). |
| §20 `message-copy-link.spec.ts:160` | 4 of 50 main jobs (5 failed
attempts), e.g. 40595483966 | The huddle row reused `"d".repeat(64)`,
which is a seeded ordinary `#general` message. | Use ids that no seed
uses, and emit only after `waitForMockChannelHeadReady`. |
| §21 `navigation.spec.ts:687` | 4 of 50 main jobs | The pending chip's
`boundingBox()` was read once, and can be null while the row mounts. |
Poll until the chip has a width. |
| §23 `sidebar-snapshot.spec.ts:358`, `:388`, `:438` | PR 40427822003
(all 3 attempts); never on main | The 500 ms loading-state budget
counted app boot, so a slow runner finished the channels read first. |
Hold the first channels read with `deferFirstChannelsRead`, assert the
loading state, then release it. |
| `project-commit-detail.spec.ts:259` | 1–2 CI failures (audit long
tail) | A 50 ms refocus timer can land mid-fill and type the description
into the name field. | Once the name field is focused, wait out a page
timer of `CREATE_PROJECT_REFOCUS_DELAY_MS`; equal-delay timers fire in
order. |
| `project-pr-review.spec.ts:3520` | 1–2 CI failures | The same timer
pattern in `CreateProjectWorkItemDialog`. | Wait for `toBeFocused()` on
the title, which only that timer can satisfy. |
| `thread-load-failure.spec.ts:79` | 1–2 CI failures |
`openWelcomeThread` branched on a one-shot `count()` and fell back to a
hover-only Reply click. | Wait for the summary row, then click it. |
| `virtualization.spec.ts:201` | 1–2 CI failures | The drop point sat 2
px above the target's bottom edge, after two fixed frames. | Drop at the
centre, after dnd-kit's live region names the target. |
| `channel-composer-overflow.spec.ts:217` | 1–2 CI failures |
Same-second emits sort by id, and the clearance was checked once. |
`createdAt: base + i` and `expect.poll`. |
| `scroll-history.spec.ts:1289` | 1–2 CI failures | A fixed 250 ms wait
before idle sampling. | Poll for coverage first. |

Local before/after counts are from the original `flakey-tests` commits
(e286a6ae0, 816c7c35e, 67185df6a). Each used a forcing that made
`main`'s copy fail with CI's signature:

| Test | Before | After |
|---|---|---|
| §2 | 3/15 | 15/15 |
| §4 | 0/10 | 10/10 |
| §6 :222 | 0/3 | 3/3 |
| §6 :297 | 0/3 | 3/3 |
| §12 | 0/3 | 3/3 |
| §15 | 0/3 | 3/3 |
| §17 | 1/4 | 4/4 |
| §19 late-wheel modes | 0/3 | 3/3 and 2/3 |
| §23 | 2/6 | 6/6 |
| project-commit-detail | 0/3 | 3/3 |
| project-pr-review | 0/3 | 3/3 |

§20, §21, `thread-load-failure:79`, `virtualization:201`,
`channel-composer-overflow:217` and `scroll-history:1289` were not
reproduced; they get the fix that matches their CI signature.

Not fixed (follow-up): `virtualization:247` also has a product-side
mode. In one frame after a prepend, `scrollHeight` can drop 458–639 px
while `scrollTop` moves only 50–114 px, so the anchor row jumps 382–457
px. That is lost anchor compensation, and this test can still fail on
it.

### 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 `virtualization.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 and mock-bridge change; the one product edit is a constant
extraction with its value unchanged. 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/virtualization.spec.ts` is also
edited by **flakey-tests-desktop-races**, which adds one test after line
929; this PR's hunks are all above line 551. They don't touch, so the
two merge cleanly in either order, as checked with `git merge-tree`.

### 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),
#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 09:59:40 +11:00

1055 lines
41 KiB
TypeScript

import { expect, test } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { 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.
// A 300ms relay delay leaves the input boundary and prepend commit as two
// distinct phases so this assertion cannot accidentally measure only the
// later anchor correction.
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();
// 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);
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. Stop early at the
// prepend commit: its anchor correction is not a boundary 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 });
const startHeight = s.scrollHeight;
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 (s.scrollHeight > startHeight + 800) break;
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 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, sawPrepend };
},
{
anchorId: committedAnchor.id,
anchorTop: committedAnchor.top,
oldHeight: before.scrollHeight,
},
);
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("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);
});