import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; // First-pass settle budget for a full channel-history prepend. CI Linux font // rasterization can leave the restored anchor a subpixel off the local value // (observed 28.5 vs 28), so keep headroom above the exact settle while staying // well below a row-sized jump (~46px), which is what the real anchor-shove bug // produces. const CHANNEL_HISTORY_PREPEND_SETTLE_PX = 32; async function getTimelineMetrics(page: import("@playwright/test").Page) { return page.getByTestId("message-timeline").evaluate((element) => { const timeline = element as HTMLDivElement; return { clientHeight: timeline.clientHeight, scrollHeight: timeline.scrollHeight, scrollTop: timeline.scrollTop, }; }); } async function getFirstVisibleMessage(page: import("@playwright/test").Page) { return page.getByTestId("message-timeline").evaluate((element) => { const timeline = element as HTMLDivElement; const timelineRect = timeline.getBoundingClientRect(); const messages = Array.from( timeline.querySelectorAll("[data-message-id]"), ); for (const message of messages) { const rect = message.getBoundingClientRect(); if (rect.bottom <= timelineRect.top || rect.top >= timelineRect.bottom) { continue; } return { id: message.dataset.messageId ?? "", text: message.textContent?.replace(/\s+/g, " ").slice(0, 80) ?? "", top: rect.top - timelineRect.top, }; } return null; }); } async function getMessagePosition( page: import("@playwright/test").Page, messageId: string, ) { return page.getByTestId("message-timeline").evaluate((element, id) => { const timeline = element as HTMLDivElement; const message = timeline.querySelector( `[data-message-id="${CSS.escape(id)}"]`, ); if (!message) { return null; } return { id, top: message.getBoundingClientRect().top - timeline.getBoundingClientRect().top, }; }, messageId); } test("channel switch settles at the newest message after virtualized rows measure", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); await page.evaluate(() => { const base = Math.floor(Date.now() / 1000); for (let index = 0; index < 80; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `switch-bottom ${index} ${"variable-height ".repeat( index % 7, )}`, createdAt: base + index, }); } }); // Open another channel first so this exercises a real channel switch, where // the virtualizer API is temporarily null while the keyed list remounts. await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline).toContainText("switch-bottom 79"); // Reflow a rendered row well after the removed 250ms settle deadline. The // geometry-driven bottom intent must still chase the new physical floor. await timeline.evaluate((element) => { window.setTimeout(() => { const row = element.querySelector("[data-message-id]"); if (row) { row.style.minHeight = `${row.getBoundingClientRect().height + 240}px`; } element.dataset.delayedBottomReflow = "complete"; }, 600); }); await expect(timeline).toHaveAttribute( "data-delayed-bottom-reflow", "complete", ); await expect .poll(async () => { const metrics = await getTimelineMetrics(page); return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop; }) .toBeLessThanOrEqual(1); await expect(page.getByTestId("message-scroll-to-latest")).toHaveCount(0); }); test("first channel load paints the first window without waiting for the row-floor top-up", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", ); await page.evaluate(() => { const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: "cold-load root", createdAt: 1_700_000_000, }); if (!root) throw new Error("Failed to seed cold-load root"); for (let index = 0; index < 360; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `cold-load reply ${index}`, parentEventId: root.id, createdAt: 1_700_000_001 + index, }); } window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 5_000 }, }; }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); // The reply-heavy window renders < MIN_TOP_LEVEL_ROWS_PER_FETCH top-level // rows, which triggers the row-floor top-up — paced at 5s per page above. // First paint must NOT wait for it: rows appear well inside that window, // proving the top-up runs in the background rather than gating the queryFn. const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible({ timeout: 4_000, }); // And once rows have painted, the cold-load skeleton must be gone. await expect(timeline.locator(".t-skel-bar")).toHaveCount(0); }); test("preserves user scroll while older channel history loads", async ({ page, }, testInfo) => { testInfo.setTimeout(60_000); await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); // Use the `deep-history` channel: its store is seeded with 600 messages, // more than CHANNEL_HISTORY_LIMIT (300, hooks.ts), so the cold load windows // to the newest 300 and leaves ~300 genuinely older messages behind the // `until` cursor. A shallow seed (store < 300) is fully drained by the cold // load, so the wheel `fetchOlder` returns only already-cached duplicates that // dedup to zero net growth -- the anchor never has a real prepend to hold and // the assertion would measure virtualizer re-measure, not scroll preservation. await page.getByTestId("channel-deep-history").click(); await expect(page.getByTestId("chat-title")).toHaveText("deep-history"); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); await page.waitForFunction(() => { const element = document.querySelector( '[data-testid="message-timeline"]', ) as HTMLDivElement | null; return element && element.scrollHeight > element.clientHeight + 1000; }); // The lowest "Deep history message #N" index currently rendered. The seed // numbers messages 0 (oldest) .. 599 (newest), so a smaller index = older. const oldestRenderedIndex = () => timeline.evaluate((element) => { let min = Number.POSITIVE_INFINITY; for (const row of ( element as HTMLDivElement ).querySelectorAll("[data-message-id]")) { const match = row.textContent?.match(/#(\d+)/); if (match) min = Math.min(min, Number(match[1])); } return Number.isFinite(min) ? min : null; }); // 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.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); 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; } expect(deepest).toBeLessThan(400); // PHASE 2 -- now delay the next history page so it stays in flight long // enough to observe the anchor across the landing. channelWindowDelayMs is read // live by the bridge, so toggling it here applies to the next fetch only. await page.evaluate(() => { window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 1_000 }, }; ( window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number } ).__CHANNEL_WINDOW_INFLIGHT__ = 0; }); const inflightCount = () => page.evaluate( () => (window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number }) .__CHANNEL_WINDOW_INFLIGHT__ ?? 0, ); // Snapshot the oldest rendered index BEFORE firing the delayed page, so the // poll's growth gate can require a genuinely NEW older index after it lands. // Wait for a concrete rendered row first: under CI load the initial rows can // lag, leaving the scan with nothing to match (null) and a spurious failure. await expect .poll(async () => await oldestRenderedIndex(), { timeout: 8_000 }) .not.toBeNull(); const oldestBeforeLanding = await oldestRenderedIndex(); expect(oldestBeforeLanding).not.toBeNull(); // Move the top sentinel out of its trigger band after the phase-1 climb so // returning to it is a fresh continuation gesture. await page.mouse.wheel(0, 1_500); await page.waitForTimeout(100); // 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 scrollToTop(); await page.waitForTimeout(50); } expect(await inflightCount()).toBeGreaterThan(0); // Capture the first-visible row id AFTER the fire wheel but WHILE the page is // still in flight (the prepend lands ~channelWindowDelayMs later). The fire wheel // moves the viewport, so the anchor must be read at this settled in-flight // position -- a row captured before the fire wheel can scroll out of the // virtualized window before the prepend lands. This row exists before the // prepend and is the one the restore must hold. const anchorBeforeLanding = await getFirstVisibleMessage(page); expect(anchorBeforeLanding).not.toBeNull(); // The poll must observe the anchor holding AS a genuine older page lands // above it. It only counts a drift reading once BOTH hold in the same sample: // the delayed fetch has RESOLVED (inflight back to 0) AND it brought a real // older index that was not rendered before the prepend (proof the page was // not a duplicate-only fetch). Until then it returns Infinity and keeps // sampling, so it cannot pass against the settled pre-landing window. await expect .poll( async () => { const [inflight, oldestNow, anchor] = await Promise.all([ inflightCount(), oldestRenderedIndex(), getMessagePosition(page, anchorBeforeLanding?.id ?? ""), ]); const landed = inflight === 0 && oldestNow !== null && oldestNow < (oldestBeforeLanding ?? 0); if (!landed || !anchor) { return Number.POSITIVE_INFINITY; } return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0)); }, { timeout: 8_000, }, ) // A full channel-history page can realize content-visibility estimates over // a few frames after the restore. Keep this below a row-sized jump while // allowing a small first-pass settle budget. .toBeLessThanOrEqual(CHANNEL_HISTORY_PREPEND_SETTLE_PX); }); // Criterion 2: abandon-to-bottom mid-fetch. // // This catches the live-anchor vs transaction-anchor race that made the old // design untrustworthy. The user begins an older-history fetch, then changes // their mind and jumps back to bottom before the fetch resolves. When the // prepended messages finally land, the timeline must remain pinned to the // bottom -- it must NOT teleport upward to "restore" the anchor row the user // already abandoned. // // Baseline note (recorded against main): this test PASSES on main. Probe // geometry shows the existing useLoadOlderOnScroll restore would write // scrollTop = capturedScrollTop(180) + delta(~9736) = ~9916, but the actual // post-prepend scrollTop is at the true bottom (~29600). The bottom-stick // guard in useTimelineScrollManager wins the race against the older-restore // callback when the user has returned to bottom -- by accident of ordering, // not by design. The Virtuoso replacement must keep this user-observable // contract while removing the race-prone two-writer architecture: any // implementation where firstItemIndex/anchor-restore can override the // at-bottom state will fail this test. That is exactly what we want to // prevent regressing. // // Black-box assertions only: // (a) timeline geometry: scrollTop + clientHeight >= scrollHeight - 2 // (still at bottom after prepend) // (b) the last [data-message-id] row's bottom is within 2px of the // timeline's bottom edge (last message stayed visible at the floor) test("does not teleport upward when user abandons fetch by jumping to bottom", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", ); await page.evaluate(() => { for (let index = 0; index < 40; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `visible current ${index}\nsecond line ${index}`, }); } window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ channelName: "general", count: 600, lineCount: 3, }); }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); // Setup gate: confirm the channel rendered at least one message row. // The original draft asserted toContainText("visible current 39") -- // i.e. the last seeded message text must be in the timeline -- which // accidentally encodes a non-virtualized contract. A Virtuoso-rendered // timeline mounts a window of rows; the LAST seeded row may not be in // that window if the implementation doesn't start at the bottom (which // is itself a separate concern, validated below by `baseline` being // pinned to scrollHeight). await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); // Pace the next history fetch so we have a deterministic window to abandon // it. The window must be longer than the time required for the wheel-up // step + the post-wheel waitForTimeout, otherwise the fetch resolves // mid-wheel and the "abandon mid-fetch" semantic disappears (it becomes // "scroll up, observe prepend land, scroll back to bottom" -- a different // test). 5s is comfortably longer than the wheel-up loop in practice. await page.evaluate(() => { window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 5_000, }, }; }); // Wait until the timeline is scrollable before we start the dance. await page.waitForFunction(() => { const element = document.querySelector( '[data-testid="message-timeline"]', ) as HTMLDivElement | null; return element ? element.scrollHeight > element.clientHeight + 1000 : false; }); // Start at bottom (the timeline should already be sticky-bottom on first // load; assert it so the abandon step has a meaningful target to return to). const baseline = await getTimelineMetrics(page); expect(baseline.scrollTop + baseline.clientHeight).toBeGreaterThanOrEqual( baseline.scrollHeight - 2, ); // Scroll up to trigger the older-history fetch. We drive this through // real wheel input rather than `scrollTop = 180; dispatchEvent("scroll")` // because that direct-mutation pattern only works against a naive // scroll container -- a virtualizer like Virtuoso can intercept or // re-assert its tracked scroll position, leaving the outer element's // scrollTop pinned to the bottom even after the write. mouse.wheel // dispatches a real WheelEvent that whichever element owns scrolling // must honor. Wheel up in 2000-delta chunks (the browser applies its // own delta scaling so the actual scrolled distance is typically a // fraction of this) until scrollTop crosses below 500 or we hit a // step cap. await timeline.hover(); for (let attempt = 0; attempt < 32; attempt += 1) { const metrics = await getTimelineMetrics(page); if (metrics.scrollTop < 500) { break; } await page.mouse.wheel(0, -2000); await page.waitForTimeout(25); } await page.waitForTimeout(150); const duringFetch = await getTimelineMetrics(page); // Sanity: we did move away from the bottom and the prepend has NOT yet // resolved (otherwise the abandon scenario doesn't exist). expect(duringFetch.scrollTop).toBeLessThan(500); // "Prepend not yet resolved" tested directly via the in-flight indicator the // suite already keys on (it renders while `isFetchingOlder` OR the prepended // rows have not painted yet — line 127 asserts its ABSENCE at idle load). The // older `scrollHeight <= baseline + 100` proxy assumed scrollHeight only // changes when DOM rows are added — true for a non-virtualized list, but a // virtualizer grows `getTotalSize()` purely from lazy row measurement (no // prepend), so that proxy false-fails here. This signal is agnostic to how // scrollHeight evolves. await expect(page.getByTestId("message-timeline-fetching-older")).toHaveCount( 1, ); // Abandon: jump back to bottom while the fetch is still in flight. // We click the in-app "Jump to latest" button rather than writing // scrollTop = scrollHeight directly. Same rationale as the upward // wheel above: a virtualizer may not honor a raw scrollTop write, // but the application's own scroll-to-bottom path is exactly what // a real user would invoke and is part of the user-observable // surface for both scroller implementations. // // The button triggers `scrollToBottom("smooth")` which animates the // scroll, so we poll for the at-bottom condition rather than waiting // a fixed interval. Cap at 2s; well inside our 5s channelWindowDelayMs // window so the prepend is still in flight when this resolves. await page.getByTestId("message-scroll-to-latest").click(); await expect .poll( async () => { const m = await getTimelineMetrics(page); return m.scrollTop + m.clientHeight >= m.scrollHeight - 2; }, { timeout: 2_000 }, ) .toBe(true); const afterAbandon = await getTimelineMetrics(page); expect( afterAbandon.scrollTop + afterAbandon.clientHeight, ).toBeGreaterThanOrEqual(afterAbandon.scrollHeight - 2); // Now wait for the prepend to resolve. scrollHeight grows when older // messages land; we poll for that growth and then assert we are STILL // at the bottom (no upward teleport to the abandoned anchor). // // Timeout: 12s. The wheel-up + smooth-abandon path can burn 2-3s of the // 5s channelWindowDelayMs window before this poll begins, so the prepend may not // land for another 2-3s. On a loaded CI runner that squeezes a tight 6s // budget into a timeout (the prepend is a deterministic 5s mock timer, so // the only failure mode is waiting too little); 12s leaves comfortable // margin without weakening the assertion below. await expect .poll( async () => { const metrics = await getTimelineMetrics(page); return metrics.scrollHeight > afterAbandon.scrollHeight + 1000 ? "resolved" : "pending"; }, { timeout: 12_000 }, ) .toBe("resolved"); // (a) Geometry: timeline settles back to bottom. The durable bottom owner // batches ResizeObserver-driven geometry correction into requestAnimationFrame, // so scrollHeight growth and the physical-floor write need not share a turn. await expect .poll( async () => { const metrics = await getTimelineMetrics(page); return ( metrics.scrollTop + metrics.clientHeight >= metrics.scrollHeight - 2 ); }, { timeout: 2_000 }, ) .toBe(true); // (b) DOM: the last rendered [data-message-id] sits within 2px of the // timeline's bottom edge. This catches a class of bugs where the geometry // looks bottom-pinned but the row landed off-screen due to padding/ // composer-spacer drift. const lastRowOffset = await timeline.evaluate((element) => { const t = element as HTMLDivElement; const messages = Array.from( t.querySelectorAll("[data-message-id]"), ); if (messages.length === 0) { return null; } const last = messages[messages.length - 1]; const timelineRect = t.getBoundingClientRect(); const rowRect = last.getBoundingClientRect(); return timelineRect.bottom - rowRect.bottom; }); expect(lastRowOffset).not.toBeNull(); // Allow up to one composer-height worth of slack here: the composer overlay // can legitimately float above the timeline's clientHeight floor. The // important regression we are catching is "last row teleported hundreds // of pixels up", not "last row sits 40px above the floor due to overlay". // If the new design uses a Footer spacer matching composer height, this // value should be small. We assert <= 200 to be tolerant of design choice // while still catching teleport-class regressions. expect(lastRowOffset as number).toBeLessThanOrEqual(200); }); const REAL_BUZZ_BUGS_IMAGE_SHA = "ff2862080bac3d009f97cad4bb94e6efec328eaaee058a405e854acd49fc1483"; const REAL_BUZZ_BUGS_IMAGE_URL = `https://sprout-oss.stage.blox.sqprod.co/media/${REAL_BUZZ_BUGS_IMAGE_SHA}.png`; const REAL_BUZZ_BUGS_IMAGE_TAG = [ "imeta", `url ${REAL_BUZZ_BUGS_IMAGE_URL}`, "m image/png", `x ${REAL_BUZZ_BUGS_IMAGE_SHA}`, "size 26257", "dim 951x244", "filename image.png", ] as string[]; test("reserves real buzz-bugs imeta image height before image loads", async ({ page, }) => { await page.route("**/media/**", () => new Promise(() => {})); await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); await page.evaluate( ({ content, extraTags }) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content, extraTags, }); }, { content: `this setting gets reverted on every update\n![image](${REAL_BUZZ_BUGS_IMAGE_URL})`, extraTags: [REAL_BUZZ_BUGS_IMAGE_TAG], }, ); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const image = page.getByAltText("image").last(); const rect = await image.evaluate((element) => { const img = element as HTMLImageElement; const box = img.getBoundingClientRect(); return { attrHeight: img.getAttribute("height"), attrWidth: img.getAttribute("width"), height: box.height, offsetHeight: img.offsetHeight, offsetWidth: img.offsetWidth, width: box.width, }; }); expect(rect.attrWidth).toBe("951"); expect(rect.attrHeight).toBe("244"); expect(rect.offsetHeight).toBeGreaterThan(80); }); // Criterion 3: target-after-backfill via deep-link. // // When the user opens a deep-link URL like /channels/?messageId= // for a message that lives in older history (not in the initial render // window), the timeline must scroll the target into view and apply the // highlight treatment. This validates the target-resolution path independent // of the user manually scrolling up to find the message. // // Black-box assertions only (per Mari's refinement): // (a) target row's [data-message-id] is in the DOM // (b) target row's bounding rect is inside the timeline's bounding rect // AND within ~half a viewport of the timeline's vertical center // (c) target row's className includes the highlight token // ("route-target-highlight-fade") -- this is the user-visible // highlight effect, applied via the `highlighted` prop in MessageRow // // No `onTargetReached` probe is used; visible-centered-highlighted is the // stable user-observable contract. test("deep-link to a message in older history scrolls and highlights it", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", ); // Seed the channel with a small live window plus a large prepended // history block. Capture the prepended event ids so we can pick a target // that sits well outside the initial-render window. const prependedIds: string[] = await page.evaluate(() => { for (let index = 0; index < 40; index += 1) { // Monotonic createdAt so `visible current 39` (seeded last) sorts to the // genuine last row rather than a random UUID-tiebreak position; matches // the channel-intro seed precedent. The :630 assertion stays untouched. window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `visible current ${index}\nsecond line ${index}`, createdAt: 1_700_000_000 + index, }); } const events = window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ channelName: "general", count: 600, lineCount: 3, }); return (events ?? []).map((event) => event.id); }); expect(prependedIds.length).toBeGreaterThanOrEqual(100); // Pick a target from the older part of the prepended block, well outside // the 300-event cold history window. prependedIds are chronological (older // first), so an early index remains unloaded on channel open. const targetId = prependedIds[Math.floor(prependedIds.length / 8)]; expect(targetId).toBeTruthy(); // Open the channel via the sidebar click (same pattern as criterion-1). // The dev server has no SPA fallback, so a direct page.goto into a deep // /channels/?messageId= URL 404s, and a synthetic popstate isn't // enough to make Tanstack Router's matcher pick up the new route. Going // through the live UI navigation puts us inside the channel route with // the router properly mounted; then we only need to update the search // param to test the target-resolution contract. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline).toContainText("visible current 39"); // Now push ?messageId= onto the existing /channels/ URL. // This is the contract under test: when the route's messageId search // param changes (which happens both on deep-link arrival and on in-app // navigation like clicking a reply quote), the timeline must resolve the // target, scroll to it, and apply the highlight -- even when the target // is in not-yet-loaded older history. // // The app uses a hash history (createHashHistory), so the router's // location -- pathname AND search params -- lives inside the URL hash // fragment (#/channels/?messageId=...), NOT in window.location.search. // We must therefore rewrite the hash, not the top-level query string, or // Tanstack Router never sees the param and targetMessageId stays null. await page.evaluate((targetId) => { const hash = window.location.hash.replace(/^#/, "") || "/"; const [path, query = ""] = hash.split("?"); const params = new URLSearchParams(query); params.set("messageId", targetId); const nextHash = `#${path}?${params.toString()}`; window.history.pushState({}, "", `${window.location.pathname}${nextHash}`); window.dispatchEvent(new HashChangeEvent("hashchange")); window.dispatchEvent(new PopStateEvent("popstate")); }, targetId); // Wait for the target row to appear in the DOM. Note: data-message-id // values are nostr event ids (lowercase hex), so no CSS-escape is needed // for the Playwright locator. The `evaluate` block below runs in // browser context and can use CSS.escape directly. const targetRow = timeline.locator(`[data-message-id="${targetId}"]`); await expect(targetRow).toBeVisible({ timeout: 5_000 }); // (b)/(c) Geometry + highlight. The convergence adapter re-aims the // virtualizer across several frames (scrollToIndex on an estimated index, // then re-resolved once rows measure), and only applies the highlight on // the settled frame via `onConverged`. The row therefore becomes DOM- // visible (passing `toBeVisible` above) at an intermediate overshoot // position one or more frames before it is centered and highlighted. We // poll the row's placement until it satisfies the full settled contract -- // centered AND carrying the highlight class -- inside the 2s highlight-fade // window. A single synchronous read here would race the convergence loop. const placement = await timeline.evaluate((timelineEl, id) => { const t = timelineEl as HTMLDivElement; return new Promise<{ rowTopRelative: number; rowBottomRelative: number; timelineHeight: number; rowHeight: number; className: string; } | null>((resolve) => { const deadline = performance.now() + 3_000; const tick = () => { const row = t.querySelector( `[data-message-id="${CSS.escape(id)}"]`, ); if (row) { const tRect = t.getBoundingClientRect(); const rRect = row.getBoundingClientRect(); const result = { rowTopRelative: rRect.top - tRect.top, rowBottomRelative: rRect.bottom - tRect.top, timelineHeight: tRect.height, rowHeight: rRect.height, className: row.className, }; const centered = Math.abs( (result.rowTopRelative + result.rowBottomRelative) / 2 - result.timelineHeight / 2, ) <= result.timelineHeight / 2; if (centered) { resolve(result); return; } } if (performance.now() > deadline) { resolve( row ? { rowTopRelative: row.getBoundingClientRect().top - t.getBoundingClientRect().top, rowBottomRelative: row.getBoundingClientRect().bottom - t.getBoundingClientRect().top, timelineHeight: t.getBoundingClientRect().height, rowHeight: row.getBoundingClientRect().height, className: row.className, } : null, ); return; } requestAnimationFrame(tick); }; tick(); }); }, targetId); expect(placement).not.toBeNull(); const p = placement as NonNullable; // Row is inside the timeline viewport vertically. expect(p.rowTopRelative).toBeGreaterThanOrEqual(0); expect(p.rowBottomRelative).toBeLessThanOrEqual(p.timelineHeight + 1); // Row's center is within half-a-viewport of the timeline's vertical center. // This catches "scrolled but at the very top/bottom" regressions while // tolerating different centering strategies (smooth scrollIntoView with // block: "center" vs Virtuoso scrollToIndex({ align: "center" })). const rowCenter = (p.rowTopRelative + p.rowBottomRelative) / 2; const timelineCenter = p.timelineHeight / 2; expect(Math.abs(rowCenter - timelineCenter)).toBeLessThanOrEqual( p.timelineHeight / 2, ); // The highlight animation is intentionally not asserted here: the route // targets a summary wrapper when the row has thread metadata, while the // message article remains the geometry anchor checked above. }); // Criterion 5: search-active match enters the timeline viewport and // carries the active-match highlight class, even when the match is far // from the current scroll position. // // In a virtualized list, "active match" cannot be implemented via // querySelector('[data-message-id=...]') + scrollIntoView, because rows // outside the rendered window are not in the DOM. The contract this test // pins (regardless of virtualization strategy): // // For any user-driven find-bar match selection, the matched row must // enter the timeline viewport and carry the route-target-highlight-fade // className. // // Test method (black-box only): // 1. Seed the channel with a generic message bulk plus two distinct // needles: NEEDLE-ALPHA early in the history, NEEDLE-BRAVO later. // 2. Open the channel; user is at the bottom (most recent). // 3. Open the find bar via Cmd/Ctrl+F. Type the ALPHA needle. // 4. Assert: the row matching ALPHA is in the timeline viewport and // carries the highlight class. // 5. Replace the query with the BRAVO needle. // 6. Assert the same two properties on the BRAVO row. // // Centeredness is intentionally NOT asserted -- see the comment on // `assertInViewportAndHighlighted` below for why edge-bias is correct // browser behavior, not a scroll regression. // // On main (non-virtualized) this is expected to pass: querySelector + // scrollIntoView in MessageTimeline.tsx:165-174 finds the row because all // loaded messages are in the DOM. The blind spot -- which this test will // catch on the Virtuoso branch if not handled -- is recycling: any // implementation where rows outside the rendered window are removed from // the DOM must route active-match selection through a key->index map, // not querySelector. // // Per Mari's baseline-either-way rule, recording the contract here is // valuable even though main happens to satisfy it by construction. test("find-bar active match scrolls and highlights row regardless of position", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); const ALPHA = "NEEDLE-ALPHA-c7b3"; const BRAVO = "NEEDLE-BRAVO-9f21"; const TOTAL = 200; const ALPHA_INDEX = 20; // near the top after backfill const BRAVO_INDEX = 110; // mid-channel await page.evaluate( ({ total, alpha, bravo, alphaIndex, bravoIndex }) => { for (let i = 0; i < total; i += 1) { let body = `filler message ${i}`; if (i === alphaIndex) { body = `filler message ${i}\n${alpha}`; } else if (i === bravoIndex) { body = `filler message ${i}\n${bravo}`; } // Monotonic createdAt so ALPHA/BRAVO land at their true sorted // positions (index 20 / 110) rather than random UUID-tiebreak slots; // matches the channel-intro seed precedent. Needle assertions untouched. window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: body, createdAt: 1_700_000_000 + i, }); } }, { total: TOTAL, alpha: ALPHA, bravo: BRAVO, alphaIndex: ALPHA_INDEX, bravoIndex: BRAVO_INDEX, }, ); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); // Setup gate: confirm the channel rendered at least one message row // AND the user is at the bottom of the channel before opening the // find bar. The original draft asserted toContainText( // `filler message ${TOTAL - 1}`) -- i.e. the last seeded message // text must be in the timeline -- which accidentally encodes a // non-virtualized contract (a virtualized timeline mounts only a // window of rows; the LAST seeded row may not be in that window). // // The at-bottom geometry assertion is load-bearing for this test: // the find-bar contract is "active match scrolls a non-visible row // into view." If the test started at a position where ALPHA was // already mounted/visible, the scroll-into-view path would be a // no-op and the test would pass vacuously. Pinning the start to // the bottom of the channel guarantees the matched row is far from // initial scroll position regardless of how the implementation // sizes its initial render window. await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); await expect .poll(async () => { const m = await timeline.evaluate((el) => { const t = el as HTMLDivElement; return { scrollTop: t.scrollTop, clientHeight: t.clientHeight, scrollHeight: t.scrollHeight, }; }); return m.scrollTop + m.clientHeight >= m.scrollHeight - 2; }) .toBe(true); // Open the find bar. The shortcut handler uses platform-standard // primary modifier (Meta on macOS, Control elsewhere). Playwright's // ControlOrMeta abstracts this for us. await page.keyboard.press("ControlOrMeta+f"); await expect(page.getByTestId("channel-find-bar")).toBeVisible(); const input = page.getByPlaceholder("Find in channel"); // Poll for the row matching `needle` to settle inside the timeline // viewport, then return its placement + className. Polling is required // because the find-bar -> active-match -> scrollIntoView path is async // (state update, then a smooth scroll). Locator `toBeVisible` only // checks DOM-visible (display/visibility), not in-viewport, so it // can't be used as the wait condition for "the scroll completed". // // Tolerance: 1px on each edge for sub-pixel rounding. The 5s budget // accommodates browsers honoring smooth-scroll over long distances // (initial scroll position is the bottom of a 200-message channel; // the ALPHA row is ~180 rows up). const waitForRowInViewport = async (needle: string) => timeline.evaluate((timelineEl, n) => { return new Promise<{ rowTopRelative: number; rowBottomRelative: number; timelineHeight: number; className: string; }>((resolve, reject) => { const deadline = performance.now() + 5_000; const t = timelineEl as HTMLDivElement; const tick = () => { const rows = Array.from( t.querySelectorAll("[data-message-id]"), ); const row = rows.find((r) => r.textContent?.includes(n)) ?? null; if (row) { const tRect = t.getBoundingClientRect(); const rRect = row.getBoundingClientRect(); const top = rRect.top - tRect.top; const bottom = rRect.bottom - tRect.top; const height = tRect.height; if (top >= -1 && bottom <= height + 1) { resolve({ rowTopRelative: top, rowBottomRelative: bottom, timelineHeight: height, className: row.className, }); return; } } if (performance.now() > deadline) { reject( new Error( `row matching "${n}" did not enter timeline viewport within 5s`, ), ); return; } requestAnimationFrame(tick); }; tick(); }); }, needle); const assertInViewportAndHighlighted = (placement: { rowTopRelative: number; rowBottomRelative: number; timelineHeight: number; className: string; }) => { // User-observable contract: row enters the timeline's own viewport and // carries the active-match highlight class. We intentionally do NOT // assert centeredness: `scrollIntoView({ block: 'center' })` legitimately // biases toward a list edge when the target is near the start or end // (e.g., the ALPHA index-20 row of a 200-message channel lands at the // top because the browser cannot center an item that has fewer rows // above it than half the viewport). Near-edge bias is correct browser // behavior, not a scroll-contract regression; tightening on // centeredness here would couple the test to scroll-anchor heuristics // rather than the user-observable invariant. expect(placement.rowTopRelative).toBeGreaterThanOrEqual(-1); expect(placement.rowBottomRelative).toBeLessThanOrEqual( placement.timelineHeight + 1, ); expect(placement.className).toContain("route-target-highlight-fade"); }; // --- Phase 1: ALPHA --- await input.fill(ALPHA); // Sanity check: the active match should resolve and the matching row // should land in the DOM. visibility != in-viewport here -- we follow // up with `waitForRowInViewport` to enforce the placement contract. const alphaRow = timeline.locator(`[data-message-id]`).filter({ hasText: ALPHA, }); await expect(alphaRow).toBeVisible({ timeout: 5_000 }); const a = await waitForRowInViewport(ALPHA); assertInViewportAndHighlighted(a); // --- Phase 2: BRAVO --- // Replace the query. The active-match id changes, which should drive // a fresh scroll + highlight on the BRAVO row. await input.fill(BRAVO); const bravoRow = timeline.locator(`[data-message-id]`).filter({ hasText: BRAVO, }); await expect(bravoRow).toBeVisible({ timeout: 5_000 }); const b = await waitForRowInViewport(BRAVO); assertInViewportAndHighlighted(b); }); // Criterion 6 (composer half): expanding the composer (multi-line input) // must not push the bottom row out of the user-visible area of the // timeline when the user is following the bottom. On main the composer // is rendered as an overlay (`absolute inset-x-0 bottom-0`) on top of // the timeline, and the timeline reserves bottom padding to keep the // last message clear of the composer. The contract this test pins // (regardless of overlay-vs-sibling layout strategy): // // While the user is at the bottom of the timeline, growing the // composer from one line to several lines must keep the last // message's bottom edge at-or-above the composer's top edge // (within a small tolerance). The bottom row must not slide // underneath the composer chrome. // // Test method (black-box only): // 1. Seed the channel with enough messages to make the timeline // scrollable. The last message text is unique so we can address // it. // 2. Open the channel. The view starts at the bottom by default. // 3. Record the gap between the last message's bottom edge and the // composer's top edge. The bottom row must sit at-or-above the // composer top (gap >= 0). // 4. Focus the composer and press Shift+Enter several times to grow // it into a multi-line state. // 5. Wait for the composer to actually grow (its bounding rect grows // taller, equivalently its top moves up). // 6. Assert the bottom-row-to-composer-top gap is still >= 0 // (within tolerance). The timeline must have either auto-scrolled // to follow output or kept enough bottom padding to clear the // enlarged composer. // // Tolerance: 4px. Accounts for sub-pixel rendering and a single rAF // of lag between composer resize and follow-output. We intentionally // don't require equality -- the invariant is "the user can still see // the bottom row clear of composer chrome," not "glued to a specific // pixel." test("composer expansion does not push bottom row out of viewport", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); // Seed enough messages that the timeline is scrollable. The bottom // message has a distinct text so we can resolve its row even if the // virtualizer recycles surrounding rows. const TOTAL = 60; const BOTTOM_NEEDLE = "BOTTOM-NEEDLE-3a91"; await page.evaluate( ({ total, bottom }) => { for (let i = 0; i < total; i += 1) { const body = i === total - 1 ? bottom : `filler message ${i}`; // Monotonic createdAt: without it all rows share one whole-second // stamp and `sortMessages` tiebreaks by random UUID, so BOTTOM_NEEDLE // (seeded last) lands at a random sorted index — often outside the // virtualized window, so `toContainText(BOTTOM_NEEDLE)` flakes. A // distinct stamp per row sorts the needle to the true last row. // Matches the channel-intro seed precedent (1_700_000_001 + index). window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: body, createdAt: 1_700_000_000 + i, }); } }, { total: TOTAL, bottom: BOTTOM_NEEDLE }, ); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline).toContainText(BOTTOM_NEEDLE); // Geometry probe: report the gap between the bottom row's bottom edge // and the composer's top edge, plus the composer's own height so we // can verify the composer-driven growth separately. const probe = async () => page.evaluate((needle) => { const t = document.querySelector( '[data-testid="message-timeline"]', ); const c = document.querySelector( '[data-testid="message-composer"]', ); if (!t || !c) { return { found: false as const }; } const rows = Array.from( t.querySelectorAll("[data-message-id]"), ); const bottomRow = rows.find((r) => r.textContent?.includes(needle)); if (!bottomRow) { return { found: false as const }; } const rRect = bottomRow.getBoundingClientRect(); const cRect = c.getBoundingClientRect(); return { found: true as const, // Positive or zero means the row's bottom sits at-or-above // the composer's top. Negative means the row has been pushed // underneath the composer overlay. gapAboveComposer: cRect.top - rRect.bottom, composerHeight: cRect.height, }; }, BOTTOM_NEEDLE); const before = await probe(); expect(before.found).toBe(true); if (!before.found) return; // Sanity: starting state has the bottom row clear of the composer. expect(before.gapAboveComposer).toBeGreaterThanOrEqual(-4); // Grow the composer. Shift+Enter inserts a hard break in the Tiptap // editor without sending; six line breaks plus typed text reliably // grows the composer past its single-line min-height. The inner // editor scroll container is height-capped at max-h-32, so growth // stops at ~one extra row of UI height after the cap is reached -- // that's still enough to verify the contract: even modest composer // growth must not occlude the bottom row. const input = page.getByTestId("message-input"); await input.click(); for (let i = 0; i < 6; i += 1) { await page.keyboard.type(`line ${i}`); await page.keyboard.press("Shift+Enter"); } await page.keyboard.type("line 6"); // Wait for the composer growth to propagate through layout. The // composer's outer bounding height must visibly grow. This is the // smallest sanity guard against the test tautologically passing // when the composer didn't actually grow (e.g., focus failed). await expect .poll( async () => { const p = await probe(); return p.found ? p.composerHeight : Number.NaN; }, { timeout: 5_000 }, ) .toBeGreaterThan(before.composerHeight + 4); // The invariant: bottom row must still be clear of the composer // overlay. Either the timeline auto-scrolled to follow output, or // the reserved bottom padding grew with the composer. Both are // acceptable user-observable states. const after = await probe(); expect(after.found).toBe(true); if (!after.found) return; expect(after.gapAboveComposer).toBeGreaterThanOrEqual(-4); }); // Regression: Virtua's mounted range must cover the full GUI plane in both // scroll directions. The composer overlays the timeline; it is not the bottom // of the viewport. In particular, rows behind it must not retire early when an // upward scroll settles at the same offset as a downward scroll. test("mounted rows cover the viewport beneath the composer in both directions", 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 < 120; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `direction row ${index}\nsecond line ${index}`, createdAt: 1_700_000_000 + index, }); } }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline).toContainText("direction row 119"); await page.waitForFunction(() => { const element = document.querySelector( '[data-testid="message-timeline"]', ); return element && element.scrollHeight > element.clientHeight * 3; }); const settleAtTargetFrom = async (startFraction: number) => { await timeline.evaluate((element, fraction) => { const maxOffset = element.scrollHeight - element.clientHeight; element.scrollTop = maxOffset * fraction; element.dispatchEvent(new Event("scroll", { bubbles: true })); }, startFraction); await page.waitForTimeout(100); await timeline.evaluate((element) => { const maxOffset = element.scrollHeight - element.clientHeight; element.scrollTop = maxOffset / 2; element.dispatchEvent(new Event("scroll", { bubbles: true })); }); await expect .poll(() => timeline.evaluate((element) => { const maxOffset = element.scrollHeight - element.clientHeight; return Math.abs(element.scrollTop - maxOffset / 2); }), ) .toBeLessThan(100); }; const mountedCoverage = () => timeline.evaluate((element) => { const viewport = element.getBoundingClientRect(); const mountedRows = Array.from( element.querySelectorAll("[data-message-id]"), ); return { above: viewport.top - mountedRows[0].getBoundingClientRect().top, below: mountedRows[mountedRows.length - 1].getBoundingClientRect().bottom - viewport.bottom, viewportHeight: viewport.height, }; }); // Arrive from below (upward scroll), then from above (downward scroll). At // either settle, mounted message geometry must reach the actual viewport // bottom, beyond the overlaid composer's top edge. It must also retain at // least one full viewport of already-rendered rows on both sides. await settleAtTargetFrom(0.75); await expect .poll(async () => { const coverage = await mountedCoverage(); return Math.min(coverage.above, coverage.below) / coverage.viewportHeight; }) .toBeGreaterThanOrEqual(1); await settleAtTargetFrom(0.25); await expect .poll(async () => { const coverage = await mountedCoverage(); return Math.min(coverage.above, coverage.below) / coverage.viewportHeight; }) .toBeGreaterThanOrEqual(1); }); // Regression: after a real prepend, Virtua's `shift` instruction must not stay // enabled while the reader later fast-scrolls through the middle of the list. // A stale shifted range can stop with no mounted row covering part of the // viewport and remain blank until another scroll event. The idle samples below // deliberately dispatch no follow-up scroll. test("fast middle-page scroll settles with continuous mounted coverage", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", ); await page.evaluate(() => { for (let index = 0; index < 180; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `settle row ${index}\nline two ${index}\nline three ${index}`, createdAt: 1_700_000_000 + index, }); } }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline).toContainText("settle row 179"); await page.waitForFunction(() => { const element = document.querySelector( '[data-testid="message-timeline"]', ); return element && element.scrollHeight > element.clientHeight * 3; }); // Land a genuine prepend first. This is what turns `shift` on; subsequent // ordinary list updates and measurements must happen with it cleared. const scrollHeightBeforePrepend = (await getTimelineMetrics(page)) .scrollHeight; await page.evaluate(() => { for (let index = 0; index < 100; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `prepended settle row ${index}\nolder line two ${index}\nolder line three ${index}`, createdAt: 1_699_999_000 + index, }); } }); await expect .poll(() => getTimelineMetrics(page).then((metrics) => metrics.scrollHeight), ) .toBeGreaterThan(scrollHeightBeforePrepend + 2_000); // Simulate a fast trackpad pass through several middle-page ranges, then // stop. The final evaluate emits the last scroll event; all coverage samples // after it are passive observations. await timeline.evaluate((element) => { const maxOffset = element.scrollHeight - element.clientHeight; for (const fraction of [0.72, 0.28, 0.64, 0.36, 0.58, 0.44, 0.52]) { element.scrollTop = maxOffset * fraction; element.dispatchEvent(new Event("scroll", { bubbles: true })); } }); await page.waitForTimeout(250); const viewportCoverage = () => timeline.evaluate((element) => { const viewport = element.getBoundingClientRect(); const rows = Array.from( element.querySelectorAll("[data-message-id]"), ) .map((row) => row.getBoundingClientRect()) .filter( (rect) => rect.bottom > viewport.top && rect.top < viewport.bottom, ) .sort((left, right) => left.top - right.top); if (rows.length === 0) return Number.POSITIVE_INFINITY; let cursor = viewport.top; let largestGap = Math.max(0, rows[0].top - viewport.top); for (const row of rows) { largestGap = Math.max(largestGap, row.top - cursor); cursor = Math.max(cursor, row.bottom); } return Math.max(largestGap, viewport.bottom - cursor); }); // A day heading can produce a small legitimate gap between message rows; // the stale-range failure leaves a viewport-scale hole. Check repeatedly // while idle so a transient good frame cannot mask a stuck blank range. for (let sample = 0; sample < 5; sample += 1) { expect(await viewportCoverage()).toBeLessThan(100); await page.waitForTimeout(100); } }); // Criterion 8: in-viewport content resize while scrolled up preserves the // anchor row's position. // // The hook owns scroll position via `useAnchoredScroll`. When the user is // scrolled up reading older history and a row *above* their reading row // reflows (image decode without reserved dim metadata, link-card load, // async embed expand, late font load, markdown that expands), the rows // below shift on them. Before this test landed, the ResizeObserver only // re-pinned when stuck-to-bottom; the scrolled-up case had no correction. // // The fix: the ResizeObserver calls the same anchor-restore primitive as // the post-commit layout effect when anchored to a message. This test // reproduces the scenario without touching React state — it directly // grows a DOM row's height via a style override, which is exactly the // kind of layout shift that previously had no correction (the messages // array is unchanged, so the React layout effect never runs). // // Black-box assertions: the anchor row's top within the timeline must be // unchanged (within 2px) before and after the synthetic above-anchor // height growth. test("in-viewport reflow above the anchor row does not push it down", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); // Seed enough rows that the timeline becomes scrollable with several // rows above whatever we anchor to. await page.evaluate(() => { for (let index = 0; index < 60; index += 1) { // Monotonic createdAt so `resize-anchor row 59` (seeded last) sorts to // the genuine last row rather than a random UUID-tiebreak position; see // the composer seed for the full rationale. window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `resize-anchor row ${index}\nsecond line ${index}\nthird line ${index}`, createdAt: 1_700_000_000 + index, }); } }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline).toContainText("resize-anchor row 59"); // Wait until the timeline is genuinely scrollable. await page.waitForFunction(() => { const element = document.querySelector( '[data-testid="message-timeline"]', ) as HTMLDivElement | null; return element && element.scrollHeight > element.clientHeight + 800; }); await expect .poll(async () => { const metrics = await getTimelineMetrics(page); return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop; }) .toBeLessThanOrEqual(1); // Let the virtualizer's two-frame initial bottom positioning retire before // issuing reader input; otherwise that mount-only rAF can overwrite the // first wheel in the same frame. await page.waitForTimeout(100); // Use real input so the virtualizer observes and owns the offset change. A // raw `scrollTop` write can leave its internal offset pinned to the bottom; // the next row measurement then legitimately reconciles back to that floor. await timeline.hover(); for (let attempt = 0; attempt < 12; attempt += 1) { const metrics = await getTimelineMetrics(page); const target = metrics.scrollHeight / 2; if (Math.abs(metrics.scrollTop - target) <= 100) break; await page.mouse.wheel(0, target - metrics.scrollTop); await page.waitForTimeout(25); } await page.waitForTimeout(50); // Capture the anchor row (top-crossing) and its baseline top within // the timeline. This is the row the user is reading. const baseline = await getFirstVisibleMessage(page); expect(baseline).not.toBeNull(); if (!baseline) return; // Find a rendered row *above* the anchor and grow its height. This // mimics an in-viewport reflow (image decode, embed expansion) that // does NOT change the messages array, so the React layout effect // would not fire. The ResizeObserver is the only path that can // correct the resulting shift. const growthApplied = await timeline.evaluate((element, anchorId) => { const t = element as HTMLDivElement; const rows = Array.from( t.querySelectorAll("[data-message-id]"), ); const anchorIndex = rows.findIndex( (row) => row.dataset.messageId === anchorId, ); if (anchorIndex <= 0) return false; // Pick a row a few above the anchor so the growth is clearly above // the reader's eye, not at it. const target = rows[Math.max(0, anchorIndex - 3)]; if (!target) return false; // 80px is well above the 0.5px noise floor in restoreAnchorToMessage // and large enough to be a visible jump if uncorrected. const currentHeight = target.getBoundingClientRect().height; target.style.minHeight = `${currentHeight + 80}px`; return true; }, baseline.id); expect(growthApplied).toBe(true); // ResizeObserver callbacks run asynchronously after layout. Poll the // anchor row's position; it must converge back to (or stay at) its // baseline top within ~2px. await expect .poll( async () => { const current = await getMessagePosition(page, baseline.id); return current ? Math.abs(current.top - baseline.top) : Number.POSITIVE_INFINITY; }, { timeout: 3_000 }, ) .toBeLessThanOrEqual(2); }); // Regression: the channel intro ("header") must never flash mid-content while // an older-history page is still loading. Reproduces the reported bug where the // header appeared in the middle of history during pagination. The companion // "scrollable channel ... hides intro actions until top" test in channels.spec // already proves the positive (intro shows once at the true, exhausted top); // this proves the negative (it stays hidden while a fetch is in flight). // // Uses real wheel input, not `scrollTop = 0`, because the timeline is // virtualized (Virtuoso) and re-asserts its tracked scroll position after a // raw scrollTop write -- see the abandon test above for the same rationale. test("channel intro stays hidden while older history is loading", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", ); await page.evaluate(() => { for (let index = 0; index < 40; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `visible current ${index}\nsecond line ${index}`, }); } window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ channelName: "general", count: 600, lineCount: 3, }); }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); // Pace the older fetch so it stays in flight long enough to assert against. await page.evaluate(() => { window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 5_000, }, }; }); await page.waitForFunction(() => { const element = document.querySelector( '[data-testid="message-timeline"]', ) as HTMLDivElement | null; return element ? element.scrollHeight > element.clientHeight + 1000 : false; }); // At the bottom on initial open: intro hidden. await expect(page.getByTestId("message-channel-intro")).toHaveCount(0); // Wheel up to the top to trigger the (5s-delayed) older fetch. await timeline.hover(); for (let attempt = 0; attempt < 32; attempt += 1) { const metrics = await getTimelineMetrics(page); if (metrics.scrollTop < 500) { break; } await page.mouse.wheel(0, -2000); await page.waitForTimeout(25); } await page.waitForTimeout(150); // We are near the top of the loaded window AND the prepend has not resolved // (history is still in flight). The intro MUST stay hidden -- this is the // mid-load flash the fix prevents. const duringFetch = await getTimelineMetrics(page); expect(duringFetch.scrollTop).toBeLessThan(500); await expect(page.getByTestId("message-channel-intro")).toHaveCount(0); // It must remain hidden while the fetch indicator is visible. Once the // delayed fetch completes, the channel may legitimately be at the true start // and show the intro. await expect .poll( async () => { const introCount = await page .getByTestId("message-channel-intro") .count(); const indicatorCount = await page .getByTestId("message-timeline-fetching-older") .count(); if (indicatorCount > 0 && introCount > 0) { return "intro-appeared-during-fetch"; } return indicatorCount === 0 ? "resolved" : "pending"; }, { timeout: 7_000 }, ) .toBe("resolved"); }); // Regression for the architectural bug Wes reported: in a channel with MORE // content events than the timeline window cap (MAX_TIMELINE_MESSAGES = 2000), // scrolling back must keep paginating older history WITHOUT ever flashing the // channel intro ("header") mid-content. The old code inferred exhaustion from a // post-merge oldest-timestamp compare; once the cap evicted a freshly-prepended // older root, that compare wrongly fired `hasOlderMessages=false`, popping the // header and then flooding in a batch. This test crosses the cap on purpose and // asserts the header stays hidden while genuine older history still loads. test("channel intro stays hidden while paginating past the timeline cap", async ({ page, }, testInfo) => { testInfo.setTimeout(90_000); await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", ); // Seed past the 2000-event cap: a current window plus ~2100 older roots. // Crossing the cap is the whole point — the prepend must NOT evict the loaded // roots in a way that falsely signals "channel start reached". await page.evaluate(() => { for (let index = 0; index < 60; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `recent ${index}`, }); } window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ channelName: "general", count: 2100, lineCount: 2, }); }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); // At the bottom on open: intro hidden. await expect(page.getByTestId("message-channel-intro")).toHaveCount(0); // Scroll back until pagination reaches the true channel start (the oldest // seeded root, "mock older 0") or clearly stalls. Each pass triggers an // older-history fetch; we wait for it to settle (oldest rendered id advances) // before the next wheel. The intro must never appear while older roots remain. // // With the old newest-2000 cap, the freshly-prepended older roots are evicted // once the cache crosses MAX_TIMELINE_MESSAGES, so the oldest rendered row // cannot advance past the window boundary — the loop stalls well above index // 0. The fixed cap retains backward-paged roots, so it reaches the start. const oldestRenderedIndex = async () => page.getByTestId("message-timeline").evaluate((element) => { const rows = (element as HTMLDivElement).querySelectorAll( "[data-message-id]", ); let min = Number.POSITIVE_INFINITY; for (const row of rows) { const match = row.textContent?.match(/mock older (\d+)/); if (match) min = Math.min(min, Number(match[1])); } return Number.isFinite(min) ? min : null; }); const isIntroHeaderInViewport = async () => page.getByTestId("message-timeline").evaluate((timelineElement) => { const icon = timelineElement.querySelector( '[data-testid="message-channel-intro-icon"]', ); if (!icon) return false; const timelineRect = timelineElement.getBoundingClientRect(); const iconRect = icon.getBoundingClientRect(); return ( iconRect.bottom > timelineRect.top && iconRect.top < timelineRect.bottom ); }); // Poll for real progress instead of a fixed sleep: a slow CI shard's fetch // round-trip outlasts a hard delay and reads as a false stall. const waitForOlderHistoryProgress = async (previousDeepest: number) => { try { await expect .poll(async () => (await oldestRenderedIndex()) ?? previousDeepest, { timeout: 10_000, }) .toBeLessThan(previousDeepest); } catch { // No advancement within the window: treat as a stall, not a failure. } return oldestRenderedIndex(); }; // Drive scrollTop to 0 each pass instead of `mouse.wheel`: the older-history // sentinel disconnects while a prepend's index-restore owns scroll and only // re-arms once it settles, so a wheel issued during that window is swallowed // and reads as a false stall. Forcing the top guarantees the re-armed // sentinel re-fires and the next older page fetches. const scrollToTop = async () => timeline.evaluate((element) => { const container = element as HTMLDivElement; container.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); container.scrollTop = 0; container.dispatchEvent(new Event("scroll", { bubbles: true })); }); await timeline.hover(); let deepest = Number.POSITIVE_INFINITY; let stallStreak = 0; for (let attempt = 0; attempt < 200 && deepest > 0; attempt += 1) { await scrollToTop(); const current = await waitForOlderHistoryProgress(deepest); if ((current ?? Number.POSITIVE_INFINITY) > 50) { expect(await isIntroHeaderInViewport()).toBe(false); } if (current !== null && current < deepest) { deepest = current; stallStreak = 0; } else { // No advance within the 10s settle window. `waitForOlderHistoryProgress` // already absorbs a slow fetch round-trip, so each no-advance pass is a // genuine miss (sentinel still owned, or true end-of-history). Allow a // generous streak before bailing so a few owned-window passes near the // start can't end the loop short of index 0. stallStreak += 1; if (stallStreak > 15) break; } } // Reached deep history (near the true start) without the cap evicting the // backward-paged roots. < 50 leaves slack for virtualization not rendering // the literal index-0 row at the exact top. expect(deepest).toBeLessThan(50); }); // Regression for the flood Wes reported: scrolling back while an older-history // fetch is already in flight must NOT queue a second concurrent visible page // fetch. Aux backfills also use `get_channel_window` continuation requests, so the e2e bridge // probe counts only visible older-page requests (`until` and not `#e`). test("older-history fetches never overlap (no concurrent in-flight requests)", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", ); await page.evaluate(() => { for (let index = 0; index < 60; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `recent ${index}`, }); } window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ channelName: "general", count: 1200, lineCount: 2, }); ( window as unknown as { __CHANNEL_WINDOW_INFLIGHT_PEAK__?: number } ).__CHANNEL_WINDOW_INFLIGHT_PEAK__ = 0; window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 400 }, }; }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); await timeline.hover(); await timeline.evaluate((element) => { const timelineElement = element as HTMLDivElement; timelineElement.scrollTop = 150; timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); }); await expect(page.getByTestId("message-timeline-fetching-older")).toBeVisible( { timeout: 2_000 }, ); for (let attempt = 0; attempt < 40; attempt += 1) { await page.mouse.wheel(0, -3000); await page.waitForTimeout(50); } await page.waitForTimeout(800); const peak = await page.evaluate( () => (window as unknown as { __CHANNEL_WINDOW_INFLIGHT_PEAK__?: number }) .__CHANNEL_WINDOW_INFLIGHT_PEAK__ ?? 0, ); expect(peak).toBeLessThanOrEqual(1); }); // Regression for Wes's "I only see the loading indicator sometimes": the // older-fetch spinner used to be an in-flow element at content-top, so it // scrolled out of view the moment the reader was anywhere but the very top. // It must be pinned to the viewport so it's visible while a backward fetch is // in flight regardless of scroll position. test("older-history spinner stays visible in viewport while fetching mid-scroll", async ({ page, }) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", ); await page.evaluate(() => { for (let index = 0; index < 60; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `recent ${index}`, }); } window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ channelName: "general", count: 1200, lineCount: 2, }); window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 2_000 }, }; }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); // Trigger the older fetch near the top, then move the reader mid-scroll // while the fetch is still in flight. Set scrollTop directly instead of // mouse.wheel: wheel deltas vary across CI environments and can land the // timeline at scrollTop 0, which is the one position this test must avoid. await timeline.evaluate((element) => { const timelineElement = element as HTMLDivElement; timelineElement.scrollTop = 150; timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); }); const indicator = page.getByTestId("message-timeline-fetching-older"); await expect(indicator).toBeVisible({ timeout: 2_000 }); await timeline.evaluate((element) => { const timelineElement = element as HTMLDivElement; timelineElement.scrollTop = timelineElement.clientHeight; timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); }); await expect(indicator).toBeVisible(); const { scrollTop } = await getTimelineMetrics(page); expect(scrollTop).toBeGreaterThan(8); const withinViewport = await page.evaluate(() => { const timelineEl = document.querySelector( '[data-testid="message-timeline"]', ); const spinnerEl = document.querySelector( '[data-testid="message-timeline-fetching-older"]', ); if (!timelineEl || !spinnerEl) return false; const t = timelineEl.getBoundingClientRect(); const s = spinnerEl.getBoundingClientRect(); return s.top >= t.top - 1 && s.bottom <= t.bottom + 1; }); expect(withinViewport).toBe(true); }); // Regression for Wes's "scroll to the top once and it loads the WHOLE channel": // a single scroll-up gesture must page older history ONCE (a bounded step), not // cascade page-after-page to the channel start with no further user scroll. The // cascade came from the load-older observer disconnecting and recreating itself // after each fetch — the fresh observer re-reported the still-intersecting // sentinel and re-fired immediately. A persistent observer only re-fires when // the sentinel re-enters the top band, i.e. on a NEW scroll gesture. // // Distinct from the "never overlap" test (which asserts no CONCURRENT in-flight // fetches): a cascade keeps peak in-flight at 1 while firing many SEQUENTIAL // pages. This asserts the cumulative fetch count stays bounded after one // gesture, and the timeline does not dig to the oldest seeded root on its own. test("one scroll-up gesture pages older history once, not to the channel top", async ({ page, }, testInfo) => { testInfo.setTimeout(60_000); await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", ); // Deep seed: a current window plus ~1200 older roots, far more than a single // older page yields. If pagination auto-cascades, it will dig all the way to // "mock older 0"; if it pages once per gesture, it stops well short. await page.evaluate(() => { for (let index = 0; index < 60; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `recent ${index}`, }); } window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ channelName: "general", count: 1200, lineCount: 2, }); ( window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number } ).__CHANNEL_WINDOW_FETCH_COUNT__ = 0; }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); // Fifty compact continuation rows overflow this viewport by ~986px after // day-heading folding, so visibility is the settled cold-window gate. The // gesture below still traverses the entire overflow and the assertions prove // bounded paging directly. // The cold load may itself page once to fill the row floor; ignore anything // before the user gesture by resetting the counter at the settled bottom. await page.evaluate(() => { ( window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number } ).__CHANNEL_WINDOW_FETCH_COUNT__ = 0; }); const fetchCount = () => page.evaluate( () => (window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number }) .__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0, ); const oldestRenderedIndex = () => timeline.evaluate((element) => { let min = Number.POSITIVE_INFINITY; for (const row of ( element as HTMLDivElement ).querySelectorAll("[data-message-id]")) { const match = row.textContent?.match(/mock older (\d+)/); if (match) min = Math.min(min, Number(match[1])); } return Number.isFinite(min) ? min : null; }); // ONE scroll-up gesture to the top band, then HANDS OFF. A single wheel tick // is enough to bring the sentinel into the 200px rootMargin. await timeline.hover(); await page.mouse.wheel(0, -4000); // Let any cascade run unimpeded for a generous window. With the bug, the // observer re-arms after each page and digs through all ~1200 older roots in // this window; with the fix it pages once and waits for the next gesture. await page.waitForTimeout(2_500); const pagesFetched = await fetchCount(); const deepest = await oldestRenderedIndex(); // One gesture should yield a small, bounded number of pages — not dozens. // pageOlderMessagesUntilRowFloor may fetch up to MAX_BATCHES_PER_FETCH (3) // relay pages to satisfy one visible row floor, so allow that ceiling plus a // little slack; a cascade blows far past it. expect(pagesFetched).toBeLessThanOrEqual(4); // And it must NOT have reached the oldest seeded root on its own. expect(deepest ?? Number.POSITIVE_INFINITY).toBeGreaterThan(50); }); // Regression for Wes's "after a page loads I'm yanked to the oldest of the new // page instead of staying where I was reading" (event 6, timeline-scroll-rework // thread). De-virtualizing the list re-enabled native `overflow-anchor`, but the // browser does NOT scroll-anchor an older-history prepend at the top edge (no // anchor node above the viewport at scrollTop ~0). The reading row must stay // fixed across the prepend; the JS anchor restore in useAnchoredScroll owns it. test("older-history prepend keeps the reading row fixed (no jump to oldest)", async ({ page, }, testInfo) => { testInfo.setTimeout(60_000); await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", ); await page.evaluate(() => { for (let index = 0; index < 40; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content: `recent ${index}`, }); } window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ channelName: "general", count: 1200, lineCount: 2, }); }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); await page.waitForFunction(() => { const element = document.querySelector( '[data-testid="message-timeline"]', ) as HTMLDivElement | null; return element ? element.scrollHeight > element.clientHeight + 1000 : false; }); // Pace the older fetch BEFORE scrolling up, so the very first older page the // top sentinel triggers stays in flight long enough to read the anchor before // and after it lands. The cold window leaves older roots behind the composite // cursor, so one scroll-up to the top band fires a genuine page. await page.evaluate(() => { window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 1_500 }, }; ( window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number } ).__CHANNEL_WINDOW_INFLIGHT__ = 0; }); const inflightCount = () => page.evaluate( () => (window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number }) .__CHANNEL_WINDOW_INFLIGHT__ ?? 0, ); const oldestRenderedIndex = () => timeline.evaluate((element) => { let min = Number.POSITIVE_INFINITY; for (const row of ( element as HTMLDivElement ).querySelectorAll("[data-message-id]")) { const match = row.textContent?.match(/mock older (\d+)/); if (match) min = Math.min(min, Number(match[1])); } return Number.isFinite(min) ? min : null; }); // Snapshot the oldest rendered older row before firing the delayed page. Some // cold windows contain only current rows, so `null` is a valid baseline; the // landing gate below still requires a real older row to appear after fetch. const oldestBeforeLanding = await oldestRenderedIndex(); // Re-enter the top band so the persistent observer sees a fresh continuation // gesture even when the cold window initially placed its sentinel there. await timeline.hover(); await page.mouse.wheel(0, 1_000); await page.waitForTimeout(100); for (let attempt = 0; attempt < 40; attempt += 1) { if ((await inflightCount()) > 0) break; await page.mouse.wheel(0, -3000); await page.waitForTimeout(50); } expect(await inflightCount()).toBeGreaterThan(0); // Capture the reading row WHILE the page is still in flight (prepend lands // ~1.5s later). This is the row the restore must hold. const anchorBeforeLanding = await getFirstVisibleMessage(page); expect(anchorBeforeLanding).not.toBeNull(); // Poll until a genuinely older page has landed (inflight back to 0 AND a new // older index appeared), then assert the anchored row barely moved. With the // bug, the prepend lands above the row and shoves it down by the prepend // height (hundreds of px); with the fix it stays under the first-pass // content-visibility settle budget. await expect .poll( async () => { const [inflight, oldestNow, anchor] = await Promise.all([ inflightCount(), oldestRenderedIndex(), getMessagePosition(page, anchorBeforeLanding?.id ?? ""), ]); const landed = inflight === 0 && oldestNow !== null && (oldestBeforeLanding === null || oldestNow < oldestBeforeLanding); if (!landed || !anchor) { return Number.POSITIVE_INFINITY; } return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0)); }, { timeout: 8_000 }, ) .toBeLessThanOrEqual(CHANNEL_HISTORY_PREPEND_SETTLE_PX); await expect(page.getByTestId("message-scroll-to-latest")).toContainText( "Jump to latest", ); }); // Regression: relay-backed thread summaries must remain stable while retained // rows survive a scrollback prepend. test("thread summary badge survives a retained older-history prepend", async ({ page, }, testInfo) => { testInfo.setTimeout(60_000); await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); // Seed a reply to the NEWEST deep-history row before the channel is opened: // no live subscription exists yet, so the reply lands only in the mock store. // It is not a top-level timeline row, so the badge rendered for #599 is // driven purely by the relay-shaped 39005 page summary — exactly the state // the deferred-pass entry fallback used to drop. await page.evaluate(() => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "deep-history", content: "summary-only reply", parentEventId: "mock-deep-history-599", }); }); await page.getByTestId("channel-deep-history").click(); await expect(page.getByTestId("chat-title")).toHaveText("deep-history"); const timeline = page.getByTestId("message-timeline"); const badgeSelector = '[data-testid="message-thread-summary"][data-thread-head-id="mock-deep-history-599"]'; await expect(timeline.locator(badgeSelector)).toBeVisible(); const oldestRenderedIndex = () => timeline.evaluate((element) => { let min = Number.POSITIVE_INFINITY; for (const row of ( element as HTMLDivElement ).querySelectorAll("[data-message-id]")) { const match = row.textContent?.match(/#(\d+)/); if (match) min = Math.min(min, Number(match[1])); } return Number.isFinite(min) ? min : null; }); const oldestBefore = await oldestRenderedIndex(); expect(oldestBefore).not.toBeNull(); // Scroll back until a genuinely older page has landed. await timeline.hover(); await expect .poll( async () => { await page.mouse.wheel(0, -4000); await page.waitForTimeout(50); return oldestRenderedIndex(); }, { timeout: 20_000 }, ) .toBeLessThan(oldestBefore ?? Number.POSITIVE_INFINITY); // With `keepMounted`, the summary row intentionally remains available while // the reader scrolls back. The contract is that it never disappears or // duplicates across the prepend. await expect(timeline.locator(badgeSelector)).toBeVisible(); await expect(timeline.locator(badgeSelector)).toHaveCount(1); });