feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+838
View File
@@ -0,0 +1,838 @@
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, {
managedAgents: [
{
pubkey:
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea",
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
// "a" matches member `alice` (Members section) and non-member `charlie`
// (Not in this channel section) — both heterogeneous lists + both sticky
// titles must stay alive under content-visibility.
await page.getByTestId("channel-management-search-users").fill("a");
await expect(page.getByText("Members", { exact: true })).toBeVisible();
await expect(
page.getByText("Not in this channel", { exact: true }),
).toBeVisible();
// A member row and an add-search (non-member) row both rendered.
await expect(
page.getByTestId("members-sidebar-people").getByText("alice"),
).toBeVisible();
await expect(
page.locator('[data-testid^="channel-user-search-result-"]').first(),
).toBeVisible();
});
test("06 — custom-section dnd reorder commits under content-visibility", async ({
page,
}) => {
await seedChannelSections(page);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// dnd-kit marks each section's wrapping row with role="button" +
// aria-roledescription="sortable" and spreads the drag listeners there, so
// the row itself is the handle. Scoping to that attribute reads the live
// section order and excludes the inner disclosure button and the (hidden)
// assign-to-section context-menu items that reuse the same names.
const headers = page.locator('[aria-roledescription="sortable"]');
const topHeader = headers.filter({ hasText: "Priority" });
const bottomHeader = headers.filter({ hasText: "Archive" });
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("08 — cascading older pages never snap the viewport toward newest", async ({
page,
}) => {
test.setTimeout(120_000);
// A real CDP wheel burst is required here: assigning scrollTop does not
// reproduce Chromium/WebKit's native wheel → scroll callback ordering. The
// old boundary rollback moved the viewport back down before the fetch
// committed; keep that pre-prepend reversal below the same 5px frame bar.
// A 300ms relay delay leaves the input boundary and prepend commit as two
// distinct phases so this assertion cannot accidentally measure only the
// later anchor correction.
await installMockBridge(page, {
deepHistoryMessageCount: 1_800,
channelWindowDelayMs: 300,
});
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Initial bottom positioning can momentarily cross the start threshold. Let
// any resulting page transaction settle before driving explicit crossings.
await page.waitForTimeout(1_000);
const sampleVisibleAnchor = (expectedId?: string) =>
timeline.evaluate(async (scroller, anchorId) => {
const s = scroller as HTMLElement;
for (let frame = 0; frame < 60; frame += 1) {
const scrollerTop = s.getBoundingClientRect().top;
const rows = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const row = anchorId
? rows.find((candidate) => candidate.dataset.messageId === anchorId)
: rows.find(
(candidate) =>
candidate.getBoundingClientRect().top >= scrollerTop,
);
if (row) {
return {
id: row.dataset.messageId ?? "",
top: row.getBoundingClientRect().top - scrollerTop,
scrollHeight: s.scrollHeight,
bottomDistance: s.scrollHeight - s.clientHeight - s.scrollTop,
};
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
throw new Error(
`anchor row ${anchorId ?? "at viewport top"} not mounted`,
);
}, expectedId);
// Load fifteen consecutive server pages in one mounted virtualizer. This
// is the production shape that exposed the intermittent end-cache snap:
// variable-height rows and repeated front insertions exercise the full
// index-shift path rather than allowing a single lucky pass.
for (let pageIndex = 0; pageIndex < 15; pageIndex += 1) {
// Leave the threshold first so Virtua emits a fresh start-edge crossing;
// initial positioning can briefly report offset 0 while mounting.
await timeline.evaluate((element) => {
element.scrollTop = 4000;
});
await page.waitForTimeout(300);
await timeline.evaluate((element) => {
element.scrollTop = 180;
});
await page.waitForTimeout(150);
const before = await sampleVisibleAnchor();
const ctrlWheelPromise =
pageIndex === 0
? timeline.evaluate(
(scroller) =>
new Promise<boolean>((resolve) => {
const s = scroller as HTMLElement;
const observer = new MutationObserver(() => {
observer.disconnect();
// Ctrl+wheel is browser zoom, not reader scroll intent. Fire
// it synchronously with the prepend DOM commit, before the
// ResizeObserver measurement batch reconciles estimated rows.
s.dispatchEvent(
new WheelEvent("wheel", { ctrlKey: true, deltaY: -100 }),
);
resolve(true);
});
observer.observe(s.firstElementChild ?? s, {
childList: true,
subtree: true,
});
}),
)
: Promise.resolve(false);
const wheelTracePromise = timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
let previousScrollTop = s.scrollTop;
let maxBoundaryRollback = 0;
let minScrollTop = s.scrollTop;
const deadline = performance.now() + 120;
while (performance.now() < deadline) {
maxBoundaryRollback = Math.max(
maxBoundaryRollback,
s.scrollTop - previousScrollTop,
);
previousScrollTop = s.scrollTop;
minScrollTop = Math.min(minScrollTop, s.scrollTop);
await new Promise((resolve) => requestAnimationFrame(resolve));
}
return { maxBoundaryRollback, minScrollTop };
});
const box = await timeline.boundingBox();
if (!box) throw new Error("timeline has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
for (const deltaY of [-60, -30, -20, -15]) {
await page.mouse.wheel(0, deltaY);
await page.waitForTimeout(12);
}
const wheelTrace = await wheelTracePromise;
expect(wheelTrace.minScrollTop).toBeLessThanOrEqual(350);
expect(wheelTrace.maxBoundaryRollback).toBeLessThan(5);
// Linux Chromium delivers CDP wheel input with more latency than macOS,
// so the burst's final delta can land AFTER the anchor baseline sample
// below. maxDrift then reports the reader's own last wheel event as
// anchor drift (measured exactly 15 = the -15 delta; changing the delta
// to -17 made the failure read 17). Gate the baseline on input settle:
// two consecutive frames with identical scrollTop. This tightens the
// assertion rather than diluting it — the baseline becomes honest and
// genuine post-prepend drift still reads as drift.
await timeline.evaluate(async (element) => {
let prior = element.scrollTop;
for (let frame = 0; frame < 30; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
if (element.scrollTop === prior) break;
prior = element.scrollTop;
}
});
const committedAnchor = await sampleVisibleAnchor(before.id);
const motion = await timeline.evaluate(
async (scroller, { anchorId, anchorTop, oldHeight }) => {
const s = scroller as HTMLElement;
let maxDrift = 0;
let sawPrepend = false;
let sawAnchorAfterPrepend = false;
let finalDrift = 0;
let stableFrames = 0;
for (let frame = 0; frame < 180; frame += 1) {
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((candidate) => candidate.dataset.messageId === anchorId);
if (s.scrollHeight > oldHeight + 800 && !sawPrepend) {
sawPrepend = true;
}
if (row) {
const top =
row.getBoundingClientRect().top - s.getBoundingClientRect().top;
const drift = Math.abs(top - anchorTop);
if (sawPrepend) {
maxDrift = Math.max(maxDrift, drift);
sawAnchorAfterPrepend = true;
stableFrames =
Math.abs(drift - finalDrift) < 0.5 ? stableFrames + 1 : 0;
finalDrift = drift;
}
}
if (sawAnchorAfterPrepend && stableFrames >= 8) break;
await new Promise((resolve) => requestAnimationFrame(resolve));
}
return { maxDrift, sawPrepend };
},
{
anchorId: committedAnchor.id,
anchorTop: committedAnchor.top,
oldHeight: before.scrollHeight,
},
);
expect(motion.sawPrepend).toBe(true);
if (pageIndex === 0) expect(await ctrlWheelPromise).toBe(true);
expect(motion.maxDrift).toBeLessThan(5);
await expect
.poll(
async () => timeline.evaluate((element) => element.scrollHeight),
{
timeout: 10_000,
},
)
.toBeGreaterThan(before.scrollHeight + 800);
// A snap to newest leaves this near zero. Keep a full viewport of history
// below the reader after every prepend, rather than checking only the
// final page and missing a transient cascade failure.
const bottomDistance = await timeline.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
);
expect(bottomDistance).toBeGreaterThan(
await timeline.evaluate((element) => element.clientHeight),
);
// Leave the boundary with real downward wheel input after this prepend.
// That reader intent retires Virtua's active prepend reconciliation, so
// later row measurements must not pull the viewport back toward the
// completed prepend before the next upward load.
const exitTracePromise = timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
const startScrollTop = s.scrollTop;
let previousScrollTop = startScrollTop;
let maxForwardTravel = 0;
let maxRollback = 0;
const deadline = performance.now() + 400;
while (performance.now() < deadline) {
const travel = s.scrollTop - startScrollTop;
maxForwardTravel = Math.max(maxForwardTravel, travel);
maxRollback = Math.max(maxRollback, previousScrollTop - s.scrollTop);
previousScrollTop = s.scrollTop;
await new Promise((resolve) => requestAnimationFrame(resolve));
}
return { maxForwardTravel, maxRollback };
});
const exitBox = await timeline.boundingBox();
if (!exitBox) throw new Error("timeline has no bounding box");
await page.mouse.move(
exitBox.x + exitBox.width / 2,
exitBox.y + exitBox.height / 2,
);
for (const deltaY of [120, 100, 80]) {
await page.mouse.wheel(0, deltaY);
await page.waitForTimeout(12);
}
const exitTrace = await exitTracePromise;
expect(exitTrace.maxForwardTravel).toBeGreaterThan(200);
expect(exitTrace.maxRollback).toBeLessThan(5);
// Loading more history must not return keepMounted to its old linear
// growth. Virtua still retains every measured size for spacer geometry;
// only the live message-row DOM stays bounded around the reader and tail.
const mountedMessageCount = await timeline
.locator("[data-message-id]")
.count();
expect(mountedMessageCount).toBeLessThan(400);
}
});
test("09 — older-page render commit waits for scroller rest under continued wheel input", async ({
page,
}) => {
test.setTimeout(60_000);
// Production shape for the WKWebView dropped-write hazard: heavy
// variable-height rows, a slow older-page fetch, and wheel input that
// KEEPS ARRIVING through fetch resolution. Every prepend-compensation
// mechanism is a scrollTop write, and macOS WebKit can drop those writes
// while trackpad momentum owns the offset — so the contract under test is
// that the fetched page's RENDER COMMIT (the scrollHeight jump) is
// deferred until input quiesces, and that the at-rest commit then holds
// the anchored row. Chromium cannot reproduce the dropped write itself;
// it CAN prove the commit-at-rest scheduling that makes it unreachable.
await installMockBridge(page, {
deepHistoryMessageCount: 1_800,
channelWindowDelayMs: 300,
});
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForTimeout(1_000);
// Mount mid-history rows clear of the load-older sentinel, then trip it.
await timeline.evaluate((element) => {
element.scrollTop = 4000;
});
await page.waitForTimeout(300);
// In-page observer: tracks the last wheel-input timestamp, captures the
// first at-rest anchor after input stops, and records when the prepend
// commit (scrollHeight jump) lands relative to the last input.
const tracePromise = timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
const baseHeight = s.scrollHeight;
let lastInputTs = 0;
let sawInput = false;
let restAnchor: { id: string; top: number } | null = null;
const onWheel = () => {
lastInputTs = performance.now();
sawInput = true;
// Input after a lull invalidates any anchor captured during it —
// the commit must be measured against the FINAL at-rest position.
restAnchor = null;
};
s.addEventListener("wheel", onWheel, { passive: true });
let commit: { ts: number; gapSinceInput: number } | null = null;
let sawSpinnerDuringHold = false;
let anchorDriftAfterCommit: number | null = null;
const deadline = performance.now() + 8_000;
while (performance.now() < deadline) {
const now = performance.now();
if (commit === null) {
if (
document.querySelector(
'[data-testid="message-timeline-fetching-older"]',
) !== null
) {
sawSpinnerDuringHold = true;
}
// First frame at rest (input quiet for 60ms — shorter than the
// gate's own window, so this reading always precedes admission):
// capture the row the at-rest commit must hold.
if (restAnchor === null && sawInput && now - lastInputTs >= 60) {
const scrollerTop = s.getBoundingClientRect().top;
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find(
(candidate) =>
candidate.getBoundingClientRect().top - scrollerTop >= 0,
);
if (row?.dataset.messageId) {
restAnchor = {
id: row.dataset.messageId,
top: row.getBoundingClientRect().top - scrollerTop,
};
}
}
if (s.scrollHeight > baseHeight + 800) {
commit = { ts: now, gapSinceInput: now - lastInputTs };
}
} else if (restAnchor !== null) {
const anchor = restAnchor;
const scrollerTop = s.getBoundingClientRect().top;
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((candidate) => candidate.dataset.messageId === anchor.id);
if (row) {
anchorDriftAfterCommit = Math.max(
anchorDriftAfterCommit ?? 0,
Math.abs(
row.getBoundingClientRect().top - scrollerTop - anchor.top,
),
);
}
// Watch a settle window after the commit, then finish.
if (now - commit.ts > 700) break;
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
s.removeEventListener("wheel", onWheel);
return {
commit,
capturedRestAnchor: restAnchor !== null,
sawSpinnerDuringHold,
anchorDriftAfterCommit,
};
});
// Trip the boundary, then keep real wheel input flowing DOWN (away from
// the boundary) through and well past the 300ms fetch resolution — the
// mid-gesture window in which the ungated build commits the page.
await timeline.evaluate((element) => {
element.scrollTop = 150;
});
const box = await timeline.boundingBox();
if (!box) throw new Error("timeline has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
for (let burst = 0; burst < 30; burst += 1) {
await page.mouse.wheel(0, 30);
await page.waitForTimeout(40);
}
const trace = await tracePromise;
// The page must eventually commit — the gate defers, never strands.
expect(trace.commit).not.toBeNull();
// The commit landed only after input quiesced. On the ungated build the
// deferred snapshot flushes as soon as the fetch resolves — between wheel
// bursts, a gap far below the quiet window — so this line is the red/green
// signal for the settle gate.
expect(trace.commit?.gapSinceInput ?? 0).toBeGreaterThanOrEqual(80);
// The reader saw the fetching affordance while the page was held.
expect(trace.sawSpinnerDuringHold).toBe(true);
// The at-rest commit held the anchored row (writes land at rest).
expect(trace.capturedRestAnchor).toBe(true);
expect(trace.anchorDriftAfterCommit ?? 0).toBeLessThan(5);
});
});
test("thread-heavy history mounts every loaded row", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Seed summaries on 120 loaded roots. Every loaded row should be realized
// immediately so first-pass scrolling never encounters Virtua's hidden
// pre-measurement state.
await page.evaluate(() => {
for (let index = 480; index < 600; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "deep-history",
content: `summary-only reply ${index}`,
parentEventId: `mock-deep-history-${index}`,
});
}
});
await page.getByTestId("channel-deep-history").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Emit after opening so the live summary path updates every loaded root,
// independent of the relay page's summary cap.
await page.evaluate(() => {
for (let index = 480; index < 600; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "deep-history",
content: `live summary-only reply ${index}`,
parentEventId: `mock-deep-history-${index}`,
});
}
});
await page.waitForTimeout(500);
await page.waitForTimeout(300);
const loadedRows = timeline.locator("[data-message-id]");
// The mock channel's current loaded window contains 50 roots; all of them
// must already exist and be painted before the first scroll gesture.
await expect(loadedRows).toHaveCount(50);
expect(
await loadedRows.evaluateAll((rows) =>
rows.every((row) => getComputedStyle(row).visibility === "visible"),
),
).toBe(true);
});
test("channel switches settle the last row above the composer", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (const [channelName, prefix] of [
["general", "switch-general"],
["engineering", "switch-engineering"],
] as const) {
for (let index = 0; index < 60; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: `${prefix}-${index}`,
createdAt: 1_700_000_000 + index,
});
}
}
});
for (const channelName of ["general", "engineering", "general"]) {
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
const timeline = page.getByTestId("message-timeline");
const composer = page.getByTestId("channel-composer-overlay");
await expect
.poll(async () =>
timeline.evaluate(
(element, composerElement) => {
const rows = Array.from(
element.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const lastRow = rows.at(-1);
if (!lastRow) return Number.POSITIVE_INFINITY;
return (
lastRow.getBoundingClientRect().bottom -
(composerElement as HTMLElement).getBoundingClientRect().top
);
},
await composer.elementHandle(),
),
)
.toBeLessThanOrEqual(1);
}
});
test("offscreen rich-row resize preserves the viewport-center anchor", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (let index = 0; index < 240; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: [
`rich resize row ${index}`,
"long wrapped text ".repeat((index % 8) + 1),
].join("\n"),
createdAt: 1_700_700_000 + index,
});
}
});
await page.getByTestId("channel-general").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
const result = await timeline.evaluate(async (element) => {
const scroller = element as HTMLDivElement;
// Retire bottom-follow intent the same way real reader input does before
// moving into detached history. A raw scrollTop assignment alone is not
// user intent and would correctly leave bottom following armed.
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
scroller.scrollTop = scroller.scrollHeight / 2;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 250));
const scrollerRect = scroller.getBoundingClientRect();
const rows = Array.from(
scroller.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const visibleRows = rows.filter((row) => {
const rect = row.getBoundingClientRect();
return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom;
});
const viewportCenter = scrollerRect.top + scroller.clientHeight / 2;
const anchor = visibleRows.reduce<HTMLElement | null>((nearest, row) => {
if (!nearest) return row;
const rowRect = row.getBoundingClientRect();
const nearestRect = nearest.getBoundingClientRect();
const rowDistance = Math.abs(
(rowRect.top + rowRect.bottom) / 2 - viewportCenter,
);
const nearestDistance = Math.abs(
(nearestRect.top + nearestRect.bottom) / 2 - viewportCenter,
);
return rowDistance < nearestDistance ? row : nearest;
}, null);
if (!anchor) throw new Error("no visible center anchor");
const rowAbove = rows
.filter((row) => row.getBoundingClientRect().bottom <= scrollerRect.top)
.at(-1);
if (!rowAbove) throw new Error("no mounted offscreen rich row above");
const anchorRect = anchor.getBoundingClientRect();
const anchorCenterOffset =
(anchorRect.top + anchorRect.bottom) / 2 - viewportCenter;
const scrollTop = scroller.scrollTop;
rowAbove.style.minHeight = `${rowAbove.getBoundingClientRect().height + 240}px`;
await new Promise((resolve) => setTimeout(resolve, 500));
const nextScrollerRect = scroller.getBoundingClientRect();
const nextAnchorRect = anchor.getBoundingClientRect();
const nextViewportCenter = nextScrollerRect.top + scroller.clientHeight / 2;
return {
anchorDrift:
(nextAnchorRect.top + nextAnchorRect.bottom) / 2 -
nextViewportCenter -
anchorCenterOffset,
scrollCorrection: scroller.scrollTop - scrollTop,
};
});
expect(Math.abs(result.anchorDrift)).toBeLessThanOrEqual(2);
expect(result.scrollCorrection).toBeGreaterThanOrEqual(238);
});
test("live tail arrivals keep a bottom-pinned virtual timeline settled", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (let index = 0; index < 60; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `live-follow seed ${index}\nsecond line ${index}`,
createdAt: 1_700_000_000 + index,
});
}
});
await page.getByTestId("channel-general").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("live-follow seed 59");
await expect
.poll(() =>
timeline.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
),
)
.toBeLessThanOrEqual(1);
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content:
"remote live-follow sentinel\nline two\nline three\nline four\nline five",
createdAt: 1_800_000_000,
});
});
await expect(page.getByText("remote live-follow sentinel")).toBeVisible();
await expect
.poll(() =>
timeline.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
),
)
.toBeLessThanOrEqual(1);
});
test("live tail arrivals stay buffered while reading and release on jump", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.getByTestId("channel-deep-history").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await timeline.evaluate((element) => {
element.scrollTop = Math.max(500, element.scrollHeight / 2);
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
const frozenHeight = await timeline.evaluate(
(element) => element.scrollHeight,
);
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "deep-history",
content: "buffered live tail sentinel",
createdAt: 1_900_000_000,
});
});
await expect(page.getByText("buffered live tail sentinel")).toHaveCount(0);
await expect(page.getByTestId("message-scroll-to-latest")).toContainText("1");
expect(await timeline.evaluate((element) => element.scrollHeight)).toBe(
frozenHeight,
);
await page.getByTestId("message-scroll-to-latest").click();
await expect(page.getByText("buffered live tail sentinel")).toBeVisible();
});