mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user