test(desktop): consolidate and stabilize scroll coverage (#1815)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-13 10:11:27 -07:00
committed by GitHub
co-authored by Pinky
parent e34e0974a1
commit d41b4c3905
3 changed files with 27 additions and 242 deletions
+27 -26
View File
@@ -159,31 +159,30 @@ test("preserves user scroll while older channel history loads", async ({
return Number.isFinite(min) ? min : null;
});
// PHASE 1 -- walk into mid-history with NO history delay. Each fetchOlder
// resolves instantly, the prepend lands, the oldest-rendered index advances,
// and the next wheel re-enters a fresh top sentinel. This sustained climb is
// how a deep seed reaches the sentinel under real wheel (the `count:2100`
// sibling relies on the same mechanic). We stop in mid-history -- not at the
// top -- so a genuine older page still sits behind the `until` cursor for
// phase 2 to fetch, and so the anchor we hold has older content arriving
// ABOVE it (the actual scroll-preservation scenario, not the at-top edge).
await timeline.hover();
let deepest = Number.POSITIVE_INFINITY;
let stallStreak = 0;
for (let attempt = 0; attempt < 120 && deepest > 250; attempt += 1) {
await page.mouse.wheel(0, -4000);
await page.waitForTimeout(70);
const current = await oldestRenderedIndex();
if (current !== null && current < deepest) {
deepest = current;
stallStreak = 0;
} else {
stallStreak += 1;
if (stallStreak > 20) break;
}
// PHASE 1 -- walk into mid-history with NO history delay. Force the timeline
// to its top and wait for an older rendered index after each fetch. A wheel
// issued while prepend restoration owns the sentinel can be swallowed, which
// made a fixed gesture loop fail before exercising the anchor invariant.
// Stop in mid-history so phase 2 still has a genuine older page to fetch above
// the reading anchor.
const scrollToTop = async () =>
timeline.evaluate((element) => {
const container = element as HTMLDivElement;
container.scrollTop = 0;
container.dispatchEvent(new Event("scroll", { bubbles: true }));
});
let deepest = (await oldestRenderedIndex()) ?? Number.POSITIVE_INFINITY;
for (let pageIndex = 0; pageIndex < 10 && deepest >= 400; pageIndex += 1) {
const previousDeepest = deepest;
await scrollToTop();
await expect
.poll(async () => (await oldestRenderedIndex()) ?? previousDeepest, {
timeout: 5_000,
})
.toBeLessThan(previousDeepest);
deepest = (await oldestRenderedIndex()) ?? previousDeepest;
}
// Confirm phase 1 actually paginated into mid-history -- if it never climbed
// off the newest window the rest of the test is meaningless.
expect(deepest).toBeLessThan(400);
// PHASE 2 -- now delay the next history page so it stays in flight long
@@ -220,10 +219,12 @@ test("preserves user scroll while older channel history loads", async ({
await page.mouse.wheel(0, 1_500);
await page.waitForTimeout(100);
// One wheel tick to fire the delayed older-history page.
// Re-enter the top sentinel and wait for the delayed request to start. Drive
// the actual scroll container because wheel input can arrive while prepend
// restoration still owns the sentinel and be discarded.
for (let attempt = 0; attempt < 50; attempt += 1) {
if ((await inflightCount()) > 0) break;
await page.mouse.wheel(0, -4000);
await scrollToTop();
await page.waitForTimeout(50);
}
expect(await inflightCount()).toBeGreaterThan(0);
@@ -390,92 +390,6 @@ test("timeline reserves mixed-media rows before fast scrollback", async ({
expect(drift.maxDrift).toBeLessThanOrEqual(120);
});
test("removed-message system row stays fixed when older history prepends", async ({
page,
}, testInfo) => {
testInfo.setTimeout(30_000);
await installMockBridge(page);
await page.goto("/");
await waitForMockTimelineBridge(page);
await page.getByTestId("channel-general").click();
const timeline = page.getByTestId("message-timeline");
await page.evaluate(() => {
for (let index = 0; index < 10; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `tombstone-neighbor ${index}`,
createdAt: 1_700_100_000 + index,
});
}
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: JSON.stringify({
actor:
"f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68",
type: "message_deleted",
}),
kind: 40099,
createdAt: 1_700_100_010,
id: "d".repeat(64),
});
});
const tombstoneKey = "d".repeat(64);
const tombstone = timeline.locator(
`[data-timeline-item-key="${tombstoneKey}"]`,
);
await expect(tombstone).toContainText("removed a message");
await tombstone.scrollIntoViewIfNeeded();
await timeline.evaluate((element, key) => {
const scroller = element as HTMLDivElement;
const row = scroller.querySelector<HTMLElement>(
`[data-timeline-item-key="${CSS.escape(key)}"]`,
);
if (!row) throw new Error("removed-message row is not mounted");
scroller.scrollTop +=
row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
}, "d".repeat(64));
const topBefore = await tombstone.evaluate(
(row) =>
row.getBoundingClientRect().top -
(
row.closest('[data-testid="message-timeline"]') as HTMLElement
).getBoundingClientRect().top,
);
await page.evaluate(() => {
for (let index = 0; index < 30; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `older-than-tombstone ${index}`,
createdAt: 1_700_000_000 + index,
});
}
});
await expect(timeline).toContainText("older-than-tombstone 29");
await expect
.poll(
async () =>
Math.abs(
(await tombstone.evaluate(
(row) =>
row.getBoundingClientRect().top -
(
row.closest('[data-testid="message-timeline"]') as HTMLElement
).getBoundingClientRect().top,
)) - topBefore,
),
{ timeout: 5_000 },
)
.toBeLessThanOrEqual(2);
});
test("timeline prepend plus late row reflow keeps the reading row stable", async ({
page,
}, testInfo) => {
-130
View File
@@ -176,136 +176,6 @@ test.describe("list virtualization", () => {
await expect(headers).toHaveCount(2);
});
test("07 — load-older prepend holds the anchored row without jitter or reconcile spin", async ({
page,
}) => {
// Install once: addInitScript re-runs on every navigation in this page, so
// each page.goto in the loop below re-applies the mock bridge.
await installMockBridge(page);
// The deep-history channel seeds 600 messages; the initial load windows to
// the newest 200, leaving 400 older behind the until cursor — enough that
// every run lands a genuine prepend. Reads the first row at/below the
// viewport top and returns scrollTop, scrollHeight, and that row's on-screen
// VIEWPORT position in ONE settled snapshot — the position the single-writer
// restore must hold steady across the prepend.
//
// Waits inside the browser for a measurement-settled frame before reading.
// The virtualizer re-windows after a scroll: for a few rAFs the mounted rows
// can all sit above the viewport top (their absolute offsets lag the new
// scrollTop) until the library mounts rows at the current position. That is
// a measurement transient, NOT the scrollTop race — scrollTop is already
// correct on those frames. Reading on such a frame would throw "no row";
// polling for a settled frame removes the flake without touching any
// race-detection threshold below (scrollTop value + viewportPos stability),
// and snapshots all three fields together so they can't skew across reads.
const sampleAnchor = (timeline: Locator) =>
timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
for (let frame = 0; frame < 60; frame += 1) {
const scrollerTop = s.getBoundingClientRect().top;
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((r) => r.getBoundingClientRect().top - scrollerTop >= 0);
if (row) {
return {
viewportPos: row.getBoundingClientRect().top - scrollerTop,
scrollTop: s.scrollTop,
scrollHeight: s.scrollHeight,
};
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
throw new Error("no anchor row mounted after 60 frames");
});
// Determinism is the bar, not pass-once. The original defect was a RACE: a
// second restore loop (the resize-observer restoring to the pre-fetch
// scrollTop of 0, fired by the load-older spinner's clientHeight shift)
// fought the anchor restore frame-by-frame; last writer won, so the anchor
// held only ~2 of 3 runs and on its losing runs scrollTop collapsed to ~0
// (view stuck at the top, anchor lost). A single prepend can go green on a
// lucky scheduling order, so this drives the prepend on SIX fresh page loads
// and asserts the anchor holds on every one — a flaky-pass fails the run.
// Fresh navigation each iteration resets the virtualizer's measurement state,
// matching the run-to-run conditions under which the race surfaced.
for (let run = 0; run < 6; run += 1) {
// Force a full document reload each iteration. Navigating straight to the
// same hash route is a same-document hash change, not a reload, so the
// virtualizer + paginated history would carry over and later runs would
// exhaust the older pages — defeating the per-run fresh-prepend premise.
await page.goto("about:blank");
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toBeVisible();
await expect(
page.locator('[data-message-id^="mock-deep-history-"]').first(),
).toBeVisible();
// Scroll up to mount mid-history rows while staying clear of the load-older
// sentinel zone (trips within 200px of the top), then let the windowed rows
// measure off their 80px estimate so the pre-prepend anchor reading is
// stable. The single trigger is the deliberate scrollTop = 0 below.
await timeline.evaluate((el) => {
el.scrollTop = 4000;
});
await page.waitForTimeout(300);
await timeline.evaluate((el) => {
el.scrollTop = 4000;
});
await page.waitForTimeout(150);
const before = await sampleAnchor(timeline);
expect(before.scrollTop).toBeGreaterThan(200);
// Trigger exactly one prepend. Scrolling to 150 trips the load-older
// sentinel (its rootMargin reaches 200px past the top) with
// previousScrollTopRef pinned near the top — the condition under which the
// resize-observer's competing restore collapsed the anchor pre-fix. After
// the single fetchOlder lands, the anchor restore carries scrollTop deep
// into the content, clear of the 200px sentinel zone, so the observer does
// NOT re-fire: one clean prepend, not the re-trigger storm that scrollTop
// 0 produces (0 keeps the sentinel tripped across every paged window down
// to the small exhaustion-tail page, which legitimately lands the top row
// near the top — masking the hold signal).
await timeline.evaluate((el) => {
el.scrollTop = 150;
});
// Anchor-hold gate (the race signal): poll until the restore has carried
// scrollTop deep into the content — past where it sat before the prepend.
// Pre-fix, the competing resize-observer restore (firing on the spinner's
// clientHeight shift, restoring to previousScrollTopRef ~150) won often
// enough that scrollTop stayed pinned near the top; this poll would then
// time out, failing the run. scrollHeight grows several frames BEFORE the
// restore moves scrollTop, so a scrollHeight gate would read mid-cycle
// near the top — the race lives in scrollTop, so the gate watches it.
await expect
.poll(async () => (await sampleAnchor(timeline)).scrollTop, {
timeout: 10_000,
})
.toBeGreaterThan(before.scrollTop);
// One settled snapshot for the remaining checks so scrollHeight and
// viewportPos come from the same frame as the held scrollTop:
// (a) the scroller grew by the prepended rows' height (genuine prepend),
// (b) the first-visible row sits where it did before the prepend.
const after = await sampleAnchor(timeline);
expect(after.scrollHeight).toBeGreaterThan(before.scrollHeight + 800);
expect(Math.abs(after.viewportPos - before.viewportPos)).toBeLessThan(
120,
);
// Reconcile terminates: two equal scrollTop reads 600ms apart prove the
// rAF loop stopped. Under the double-writer bug the library re-scheduled
// one rAF per frame for the full 5s MAX_RECONCILE_MS valve — still churning
// 600ms apart.
const settled1 = await timeline.evaluate((el) => el.scrollTop);
await page.waitForTimeout(600);
const settled2 = await timeline.evaluate((el) => el.scrollTop);
expect(Math.abs(settled1 - settled2)).toBeLessThan(2);
}
});
test("08 — cascading older pages never snap the viewport toward newest", async ({
page,
}) => {