mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
## Summary
This fixes the Desktop Smoke E2E failure that turned `main` red at
af7979e ([run](https://github.com/block/buzz/actions/runs/39295686771)).
The failing test was `video-attachment.spec.ts` "playback speed persists
across videos and reloads". The cause is a real timeline bug, not a
problem in the test.
When the reader leaves the bottom, `MessageTimeline` freezes the live
tail and sets a one-shot flag. The flag tells it to ignore the next "at
bottom" report, because freezing the tail can shorten Virtua's model and
produce a false one. If nothing is buffered, the freeze doesn't change
the model, so that false report never arrives. The flag then stays set
and swallows the reader's real return to the bottom. If the reader gets
back to the bottom in one scroll event, the tail stays frozen and new
messages wait behind a "1 new message" pill even though the reader is
already at the bottom. A scrollbar jump, the End key, or Playwright's
scroll-into-view retry all return in one event.
In CI, the Play click was intercepted, so Playwright retried it with
different scroll alignments. That moved the timeline away from the
bottom (scrollTop 430 → 289 → 430, seen in the run's trace), and the
second video message was buffered and never rendered.
Fix: once the freeze commits with nothing buffered, clear the flag. If
the reader is already back at the bottom, release the tail.
### Related issue
None found. No open PR touches this.
### Testing
- New smoke test `virtualization.spec.ts` "a single scroll back to the
bottom releases the live tail": failed 10/10 before the fix, passed
10/10 after (`--repeat-each=10`).
- `video-attachment.spec.ts` "playback speed persists…": 20/20 passed
with `--repeat-each=20`.
- Smoke runs of `virtualization`, `scroll-history`, `video-attachment`,
`messaging`, `smoke`, `file-attachment` and `thread-follow`: 188 passed.
3 failed with a blank page before the first click, and all 3 passed on
re-run. Unmodified `main` shows the same failure locally, so it's a
local preview-server flake and unrelated to this change.
- `pnpm typecheck`, `pnpm check`, and `pnpm test` (6829 + 160 tests) all
pass.
Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
978 lines
38 KiB
TypeScript
978 lines
38 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) {
|
|
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");
|
|
const destination = {
|
|
x: to.x + to.width / 2,
|
|
y: to.y + to.height - 2,
|
|
};
|
|
await page.evaluate(async ({ x, y }) => {
|
|
document.dispatchEvent(
|
|
new PointerEvent("pointermove", {
|
|
bubbles: true,
|
|
buttons: 1,
|
|
clientX: x,
|
|
clientY: y,
|
|
isPrimary: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
}),
|
|
);
|
|
await new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
});
|
|
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);
|
|
|
|
// 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);
|
|
const wheelTracePromise = timeline.evaluate(async (scroller) => {
|
|
const s = scroller as HTMLElement;
|
|
let previousScrollTop = s.scrollTop;
|
|
let maxBoundaryRollback = 0;
|
|
let minScrollTop = s.scrollTop;
|
|
const deadline = performance.now() + 120;
|
|
while (performance.now() < deadline) {
|
|
maxBoundaryRollback = Math.max(
|
|
maxBoundaryRollback,
|
|
s.scrollTop - previousScrollTop,
|
|
);
|
|
previousScrollTop = s.scrollTop;
|
|
minScrollTop = Math.min(minScrollTop, s.scrollTop);
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
}
|
|
return { maxBoundaryRollback, minScrollTop };
|
|
});
|
|
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 [-60, -30, -20, -15]) {
|
|
await page.mouse.wheel(0, deltaY);
|
|
await page.waitForTimeout(12);
|
|
}
|
|
const wheelTrace = await wheelTracePromise;
|
|
expect(wheelTrace.minScrollTop).toBeLessThanOrEqual(350);
|
|
expect(wheelTrace.maxBoundaryRollback).toBeLessThan(5);
|
|
// Linux Chromium delivers CDP wheel input with more latency than macOS,
|
|
// so the burst's final delta can land AFTER the anchor baseline sample
|
|
// below. maxDrift then reports the reader's own last wheel event as
|
|
// anchor drift (measured exactly 15 = the -15 delta; changing the delta
|
|
// to -17 made the failure read 17). Gate the baseline on input settle:
|
|
// two consecutive frames with identical scrollTop. 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;
|
|
for (let frame = 0; frame < 30; frame += 1) {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
if (element.scrollTop === prior) break;
|
|
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.
|
|
const exitTracePromise = timeline.evaluate(async (scroller) => {
|
|
const s = scroller as HTMLElement;
|
|
const startScrollTop = s.scrollTop;
|
|
let previousScrollTop = startScrollTop;
|
|
let maxForwardTravel = 0;
|
|
let maxRollback = 0;
|
|
const deadline = performance.now() + 400;
|
|
while (performance.now() < deadline) {
|
|
const travel = s.scrollTop - startScrollTop;
|
|
maxForwardTravel = Math.max(maxForwardTravel, travel);
|
|
maxRollback = Math.max(maxRollback, previousScrollTop - s.scrollTop);
|
|
previousScrollTop = s.scrollTop;
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
}
|
|
return { maxForwardTravel, maxRollback };
|
|
});
|
|
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 [120, 100, 80]) {
|
|
await page.mouse.wheel(0, deltaY);
|
|
await page.waitForTimeout(12);
|
|
}
|
|
const exitTrace = await exitTracePromise;
|
|
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);
|
|
});
|