mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1067mpdna4vy22u9eltmmhtdfw56kww6ve2aze025dtfm4pxg07nqztjfhm <7ebdb0b67dab08a570b9faf7bbada97535673b4ccaba2cbd546ad3ba84c87fa6@sprout-oss.stage.blox.sqprod.co>
309 lines
14 KiB
TypeScript
309 lines
14 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import type { Locator, Page } from "@playwright/test";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
|
|
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();
|
|
const to = await target.boundingBox();
|
|
if (!from || !to) throw new Error("drag handles not laid out");
|
|
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 + 10);
|
|
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, {
|
|
steps: 10,
|
|
});
|
|
await page.mouse.up();
|
|
}
|
|
|
|
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);
|
|
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" });
|
|
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, topHeader, 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("07 — load-older prepend holds the anchored row without jitter or reconcile spin", async ({
|
|
page,
|
|
}) => {
|
|
// Install once: addInitScript re-runs on every navigation in this page, so
|
|
// each page.goto in the loop below re-applies the mock bridge.
|
|
await installMockBridge(page);
|
|
|
|
// The deep-history channel seeds 600 messages; the initial load windows to
|
|
// the newest 200, leaving 400 older behind the until cursor — enough that
|
|
// every run lands a genuine prepend. Reads the first row at/below the
|
|
// viewport top and returns scrollTop, scrollHeight, and that row's on-screen
|
|
// VIEWPORT position in ONE settled snapshot — the position the single-writer
|
|
// restore must hold steady across the prepend.
|
|
//
|
|
// Waits inside the browser for a measurement-settled frame before reading.
|
|
// The virtualizer re-windows after a scroll: for a few rAFs the mounted rows
|
|
// can all sit above the viewport top (their absolute offsets lag the new
|
|
// scrollTop) until the library mounts rows at the current position. That is
|
|
// a measurement transient, NOT the scrollTop race — scrollTop is already
|
|
// correct on those frames. Reading on such a frame would throw "no row";
|
|
// polling for a settled frame removes the flake without touching any
|
|
// race-detection threshold below (scrollTop value + viewportPos stability),
|
|
// and snapshots all three fields together so they can't skew across reads.
|
|
const sampleAnchor = (timeline: Locator) =>
|
|
timeline.evaluate(async (scroller) => {
|
|
const s = scroller as HTMLElement;
|
|
for (let frame = 0; frame < 60; frame += 1) {
|
|
const scrollerTop = s.getBoundingClientRect().top;
|
|
const row = Array.from(
|
|
s.querySelectorAll<HTMLElement>("[data-message-id]"),
|
|
).find((r) => r.getBoundingClientRect().top - scrollerTop >= 0);
|
|
if (row) {
|
|
return {
|
|
viewportPos: row.getBoundingClientRect().top - scrollerTop,
|
|
scrollTop: s.scrollTop,
|
|
scrollHeight: s.scrollHeight,
|
|
};
|
|
}
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
}
|
|
throw new Error("no anchor row mounted after 60 frames");
|
|
});
|
|
|
|
// Determinism is the bar, not pass-once. The original defect was a RACE: a
|
|
// second restore loop (the resize-observer restoring to the pre-fetch
|
|
// scrollTop of 0, fired by the load-older spinner's clientHeight shift)
|
|
// fought the anchor restore frame-by-frame; last writer won, so the anchor
|
|
// held only ~2 of 3 runs and on its losing runs scrollTop collapsed to ~0
|
|
// (view stuck at the top, anchor lost). A single prepend can go green on a
|
|
// lucky scheduling order, so this drives the prepend on SIX fresh page loads
|
|
// and asserts the anchor holds on every one — a flaky-pass fails the run.
|
|
// Fresh navigation each iteration resets the virtualizer's measurement state,
|
|
// matching the run-to-run conditions under which the race surfaced.
|
|
for (let run = 0; run < 6; run += 1) {
|
|
// Force a full document reload each iteration. Navigating straight to the
|
|
// same hash route is a same-document hash change, not a reload, so the
|
|
// virtualizer + paginated history would carry over and later runs would
|
|
// exhaust the older pages — defeating the per-run fresh-prepend premise.
|
|
await page.goto("about:blank");
|
|
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-message-id^="mock-deep-history-"]').first(),
|
|
).toBeVisible();
|
|
|
|
// Scroll up to mount mid-history rows while staying clear of the load-older
|
|
// sentinel zone (trips within 200px of the top), then let the windowed rows
|
|
// measure off their 80px estimate so the pre-prepend anchor reading is
|
|
// stable. The single trigger is the deliberate scrollTop = 0 below.
|
|
await timeline.evaluate((el) => {
|
|
el.scrollTop = 4000;
|
|
});
|
|
await page.waitForTimeout(300);
|
|
await timeline.evaluate((el) => {
|
|
el.scrollTop = 4000;
|
|
});
|
|
await page.waitForTimeout(150);
|
|
const before = await sampleAnchor(timeline);
|
|
expect(before.scrollTop).toBeGreaterThan(200);
|
|
|
|
// Trigger exactly one prepend. Scrolling to 150 trips the load-older
|
|
// sentinel (its rootMargin reaches 200px past the top) with
|
|
// previousScrollTopRef pinned near the top — the condition under which the
|
|
// resize-observer's competing restore collapsed the anchor pre-fix. After
|
|
// the single fetchOlder lands, the anchor restore carries scrollTop deep
|
|
// into the content, clear of the 200px sentinel zone, so the observer does
|
|
// NOT re-fire: one clean prepend, not the re-trigger storm that scrollTop
|
|
// 0 produces (0 keeps the sentinel tripped across every paged window down
|
|
// to the small exhaustion-tail page, which legitimately lands the top row
|
|
// near the top — masking the hold signal).
|
|
await timeline.evaluate((el) => {
|
|
el.scrollTop = 150;
|
|
});
|
|
|
|
// Anchor-hold gate (the race signal): poll until the restore has carried
|
|
// scrollTop deep into the content — past where it sat before the prepend.
|
|
// Pre-fix, the competing resize-observer restore (firing on the spinner's
|
|
// clientHeight shift, restoring to previousScrollTopRef ~150) won often
|
|
// enough that scrollTop stayed pinned near the top; this poll would then
|
|
// time out, failing the run. scrollHeight grows several frames BEFORE the
|
|
// restore moves scrollTop, so a scrollHeight gate would read mid-cycle
|
|
// near the top — the race lives in scrollTop, so the gate watches it.
|
|
await expect
|
|
.poll(async () => (await sampleAnchor(timeline)).scrollTop, {
|
|
timeout: 10_000,
|
|
})
|
|
.toBeGreaterThan(before.scrollTop);
|
|
|
|
// One settled snapshot for the remaining checks so scrollHeight and
|
|
// viewportPos come from the same frame as the held scrollTop:
|
|
// (a) the scroller grew by the prepended rows' height (genuine prepend),
|
|
// (b) the first-visible row sits where it did before the prepend.
|
|
const after = await sampleAnchor(timeline);
|
|
expect(after.scrollHeight).toBeGreaterThan(before.scrollHeight + 800);
|
|
expect(Math.abs(after.viewportPos - before.viewportPos)).toBeLessThan(
|
|
120,
|
|
);
|
|
|
|
// Reconcile terminates: two equal scrollTop reads 600ms apart prove the
|
|
// rAF loop stopped. Under the double-writer bug the library re-scheduled
|
|
// one rAF per frame for the full 5s MAX_RECONCILE_MS valve — still churning
|
|
// 600ms apart.
|
|
const settled1 = await timeline.evaluate((el) => el.scrollTop);
|
|
await page.waitForTimeout(600);
|
|
const settled2 = await timeline.evaluate((el) => el.scrollTop);
|
|
expect(Math.abs(settled1 - settled2)).toBeLessThan(2);
|
|
}
|
|
});
|
|
});
|