mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Prepared by Brain on Wes’s behalf. ## Summary Reduce startup request amplification and let quota-refused background work recover without changing relay quotas or bypassing the existing Send gate. - Coalesce nest regeneration into one worker plus the latest pending generation. Keep the stale-write fence; bound the archive read so an old workspace cannot strand newer work. - Claim pending channel subscriptions before awaiting setup; cancel removed/unmounted entries and preserve still-owned subscriptions across reconnect handoff. - Recheck extended cooldown deadlines before retry dispatch; distinguish an explicit zero-second hint from a missing hint. - Recover native finite/unread queries within their original caller deadline and a three-retry budget, preserving verified unique events, complete filters and terminal errors. - Pace renderer initial/live-CLOSED REQs at one per 250ms: visible channel and explicitly interactive TTS/read-state/observer owners first, then live-only auxiliaries, then background history. Publication and finite queries bypass this queue. Reconnect replay now promotes explicit interactive owners alongside the visible channel with stable registration-order ties; its existing capped batches and cooldown remain intact. **Scope at `867495426`:** 39 source/test files, including 15 production files (+665/-235) and 24 test/fixture files. The diagnostic recorder, HTTP instrumentation, trace launcher and raw captures are deliberately excluded. No profile reset, grouping, quota increase or new global scheduler. ## Before / after evidence These are observations from instrumented, human-operated development Tauri/Vite runs against a production relay, plus explicitly identified controlled regressions—not a packaged-release startup benchmark. | Measurement | Before | After | Evidence boundary | | --- | --- | --- | --- | | Archive snapshot requests (kind 13535) | 290 | 3 | Recorder-only baseline → nest-only intermediate candidate; unequal 67.436s / 39.273s captures and hydration differences | | Archive responses: HTTP 200 / 429 | 277 / 13 | 3 / 0 | Same baseline/nest-only captures; not a claim that all quota pressure disappeared | | Successful archive responses’ advertised content-length sum | 21,208,505 bytes | 229,695 bytes | Header values, not actual transferred bytes | | 154-channel StrictMode replay | 308 REQs | 154 REQs | Controlled real React hook → real session → mocked Tauri transport regression | | Add one channel while 154 setups are pending | 309 REQs | 155 REQs | Same production-boundary regression; ordinary mount remains 154 → 154 | | Broad renderer request burst / spacing | 110 broad REQs within 10.766ms in pre-drain capture | 250.061ms minimum / 252.131ms median between broad REQ writes in final longer capture | Different attended runs; pacing rather than a universal request-rate cap | | Native unread query outcomes | Baseline: 8 EOSE / 146 quota CLOSED out of 154 queries | Final longer run: all 156 observed queries reach EOSE; eight refused queries each recover after two refusals | Different runs/workloads; query completion, not badge rendering | ### Timing metrics | Observation | Measured value | | --- | ---: | | Baseline’s two accepted chat EVENT socket writes → positive OK | 173.340ms / 85.556ms | | Nest-only intermediate candidate’s two accepted writes → positive OK | 277.854ms / 78.775ms | | Final candidate, short run, accepted chat writes → positive OK | 80.636ms / 77.506ms | | Final candidate, longer run, accepted chat writes → positive OK | 120.712ms / 86.895ms | | Last initial EOSE among 163 observed broad channel registrations, longer run | 64.550s after trace start | | Last EOSE among 156 observed native unread queries, longer run | 73.972s after trace start | | Native retry waits after 3s then 0s hints, longer run | 3000.7–3002.8ms, then 2001.5–2002.6ms | | Renderer finite-query retry after a 2s hint, longer run | 2061.365ms | Both Sends in each final run were accepted, with no recorded negative OK. Every quota-refused query in the longer run subsequently completed the same query. Its two Sends happened early (17.878s and 21.655s), not near the end. These small samples do not support a percentage latency improvement. **Tradeoffs and limits** - 296 background subscriptions imply about 74s of paced registration before extra cooldowns/high-priority work. The longer capture observed 163 registrations; it did not independently enumerate the expected membership set or record a queue-empty marker. - This is not a global 4-REQ/s cap: finite queries, native requests and reconnect replay are outside the drain and still share relay quota. Quota refusals recover rather than disappear. - Socket-write → OK starts after composer submission and any publisher gate wait. No click-to-visible, launch-to-usable or badge-paint latency is claimed. - Native EOSE is not proof of command return, classification, React state application or visible unread badges. Existing per-channel kinds, since floors, limits, h-less routing, unread lease/scope guard and eight-query concurrency remain intact. - The attended measurements belong to the instrumented candidate, not a fresh release run of this fixes-only commit. At the initial packaging checkpoint (`1bf3d060`), eighteen of twenty files were byte-identical; the other two removed diagnostic gate calls and isolated the nest test’s temporary paths. The subsequent interactive priority/reconnect and readiness fixes have controlled hook/session/browser evidence, not another attended voice/performance run. ### Evidence provenance (captures retained locally, not uploaded) | Capture | SHA-256 | | --- | --- | | Baseline `STARTUP_20260921T194814Z_98811.jsonl` | `f4f0a89ad9aea91084bd6a7432510488166de4cbc906600d88c26d557f380994` | | Nest-only `STARTUP_20260921T202610Z_28642.jsonl` | `ce517b02a3f717c36d96b36bb8616e8fa80b03b12b356bacfd40475925ea3f3e` | | Final short `STARTUP_20260922T013628Z_13506.jsonl` | `bfea6f3cf80cd7b7e4e676632a3df2f16271fade5bfa0edec72b7bcbbe7839ff` | | Final longer `STARTUP_20260922T014637Z_15846.jsonl` | `f4abece613d108c4c2fe3cb808e633a0486bdc9993ca92323822dc00c3fae6ec` | Final instrumented build fingerprint: `b0b4fc27d793c3e643f4e10289b5afdb4620ee2e13a8b33fa0595dd3417fd5ca`. All observed dropped-record counters in these final captures were zero. Source manifests and offline correlation analyses are retained locally; no raw captures, profile data or agent sessions are part of this PR. ## Validation Current head: `86749542657a60abdfe82eed5cc576d3e162fe5d`, based on `77729abfb692b25a0f4ec4a69add86af2e32c0dd`. Review follow-ups: - `8842dbd8`: explicit interactive TTS admission using the existing main-window owner, independent of companion/timeline selection. Real-hook/session/IPC coverage with 296 cold owners proves initial and CLOSED priority, unchanged filters/cooldown/pacing, background progress and deduplicated speech. - `8cb088106`: reconnect preserves interactive priority even with no visible channel. Stable ordering among foreground ties; real hook/session reconnect regressions cover cooldown, capped batching, all 297 owners, deduplication and unmount cancellation. Independent reviewer mutation restored the prior replay implementation, failed both reconnect rows, then passed the restored candidate. - `68e76d7c`: read-state and observer reuse interactive priority. Stop and ModelPicker wait on the existing observer owner inside send callbacks, after installing result listeners/deadlines. No new owner, timer or scheduler; inherited CLOSED/fallback/error semantics remain, rather than a new strict EOSE guarantee. The real Stop panel browser probe holds observer IPC across the click and checks observer-before-control order and correlated outcome. ModelPicker component ordering is source-reviewed only. - Mock live admission now replays matching stored history with per-filter time/limit constraints, deduplication, untagged auxiliary channel fallback and authoritative explicit h-tags. Kind-specific fixture admission waits and retry-resolving hover actions fix startup-dependent test setup without weakening event/badge assertions. - `48e9c0dad` and `867495426`: steady-state scroll-prepend setup observes live admission, subsequent head dispatch, successful/idle query completion and initial bottom geometry. Phase-2 genuine-growth and ≤32px anchor assertions are unchanged. This isolates steady-state prepending: it **does not fix or certify production early-scroll/head-reset behavior**. Current validation: - Normal push hooks at `867495426`: **6,582/6,582 renderer tests**, zero failed/skipped/cancelled; lint/static checks, typecheck and differential file-size gate passed. Native source is unchanged from the earlier checkpoint; no new native run is claimed for these renderer/test follow-ups. - E2E build passed at `48e9c0dad` plus the exact subsequently committed test-only delta (spec blob `469be2eed868c677ba80b859cc871208000fee60`). The completed affected-smoke run passed **153/153** across agent-control, badge, entity links, messaging, profile status and scroll history; the full scroll file additionally passed twice (**36/36**). No full CI-smoke pass is claimed from this six-file local run. - Pinky independently reviewed production and test follow-ups, including the final scroll spec blob `469be2eed868c677ba80b859cc871208000fee60`, with no blocking finding. Review is source evidence, not an independent test run. - **Not merge-ready:** new-head required CI must complete, exact-range security review requires authorization, and GitHub currently reports REVIEW_REQUIRED; fresh exact-head reviewer approval is still needed. No approval, dismissal or merge performed. No new packaged/native audio, launch-to-usable or badge-paint observation is claimed. Initial fixes-only checkpoint validation (`1bf3d06024d284a63326fdd353baced0842aeb81`): - Pre-commit formatting hooks passed; worktree clean. - Renderer package hooks: 6,570 tests passed, zero failed/skipped/cancelled; lint/static checks, TypeScript and differential file-size gate passed. - Native package hook passed: default and mesh-enabled all-target Clippy, then the complete native workspace test suite. App library: 3,222 passed / 0 failed / 19 ignored; terminal/integration suites passed. The real nest trigger/stalled-body test passed on macOS. - Independent whole-change source review: no blocking findings at this commit; all 20 file hashes matched the committed blobs and review/validation manifests. - Regression coverage includes real hook/session cancellation, StrictMode/membership re-entry, reconnect ownership, extended/zero cooldowns, burst pacing, auxiliary readiness (including EOSE before IPC settles), native socket refusal/retry/deadline/cancellation, unread fetch/classification and real nest trigger/HTTP/file persistence. - Earlier isolated renderer mutations that started readiness before enqueue or removed auxiliary priority each failed the real presence regression; restored controls passed. No native mutation claim. - Nest fixture uses explicit app-data/nest paths instead of assuming Windows known-folder APIs obey HOME/APPDATA. Windows runtime execution is not claimed; CI/build results remain separate. Originating conversation: buzz://message?channel=46ced308-71df-495d-a144-4ebf08334703&id=bc43c3422a3b0b2141268d0fa149b898421c824992b662aed8b73c4eee50404f No approval or merge recommendation is implied by opening this PR. --------- Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2213 lines
88 KiB
TypeScript
2213 lines
88 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
import { waitForMockChannelHeadReady } from "../helpers/channelHeadReady";
|
|
|
|
// 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<HTMLElement>("[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<HTMLElement>(
|
|
`[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<HTMLElement>("[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",
|
|
);
|
|
|
|
// The 600-row seed exceeds the 50-row head window, leaving genuinely older
|
|
// pages behind its composite cursor. A fully drained seed would only measure
|
|
// virtualizer re-measurement, not scroll preservation across real growth.
|
|
await page.getByTestId("channel-deep-history").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
|
|
|
|
await waitForMockChannelHeadReady(
|
|
page,
|
|
"deep-history",
|
|
"feedf00d-0000-4000-8000-000000000007",
|
|
);
|
|
const timeline = page.getByTestId("message-timeline");
|
|
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<HTMLElement>("[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<HTMLElement>("[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 SYNTHETIC_WIDE_IMAGE_SHA =
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
const SYNTHETIC_WIDE_IMAGE_URL = `https://media.example.com/media/${SYNTHETIC_WIDE_IMAGE_SHA}.png`;
|
|
const SYNTHETIC_WIDE_IMAGE_TAG = [
|
|
"imeta",
|
|
`url ${SYNTHETIC_WIDE_IMAGE_URL}`,
|
|
"m image/png",
|
|
`x ${SYNTHETIC_WIDE_IMAGE_SHA}`,
|
|
"size 24576",
|
|
"dim 960x240",
|
|
"filename sample-banner.png",
|
|
] as string[];
|
|
|
|
test("reserves imeta image height before a synthetic image loads", async ({
|
|
page,
|
|
}) => {
|
|
await page.route(SYNTHETIC_WIDE_IMAGE_URL, () => 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: `Synthetic wide image fixture\n`,
|
|
extraTags: [SYNTHETIC_WIDE_IMAGE_TAG],
|
|
},
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const image = page.getByAltText("sample banner").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("960");
|
|
expect(rect.attrHeight).toBe("240");
|
|
expect(rect.offsetHeight).toBeGreaterThan(80);
|
|
});
|
|
|
|
// Criterion 3: target-after-backfill via deep-link.
|
|
//
|
|
// When the user opens a deep-link URL like /channels/<id>?messageId=<targetId>
|
|
// 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/<id>?messageId=<id> 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=<targetId> onto the existing /channels/<id> 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/<id>?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<HTMLElement>(
|
|
`[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<typeof placement>;
|
|
|
|
// 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("unified channel search opens rows regardless of history 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);
|
|
|
|
const openScopedSearchResult = async (needle: string) => {
|
|
await page.keyboard.press("ControlOrMeta+f");
|
|
await expect(page.getByTestId("search-channel-scope-chip")).toHaveText(
|
|
/#general/,
|
|
);
|
|
|
|
await page.getByTestId("search-dialog-input").fill(needle);
|
|
const result = page
|
|
.getByTestId("search-results")
|
|
.getByRole("option")
|
|
.filter({ hasText: needle });
|
|
await expect(result).toBeVisible();
|
|
await result.click();
|
|
};
|
|
|
|
// 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<HTMLElement>("[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 assertInViewport = (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,
|
|
);
|
|
};
|
|
|
|
// --- Phase 1: ALPHA ---
|
|
await openScopedSearchResult(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);
|
|
assertInViewport(a);
|
|
|
|
// --- Phase 2: BRAVO ---
|
|
// Reopen unified channel search for a second result. Selecting it should
|
|
// drive a fresh route-target scroll + highlight on the BRAVO row.
|
|
await openScopedSearchResult(BRAVO);
|
|
const bravoRow = timeline.locator(`[data-message-id]`).filter({
|
|
hasText: BRAVO,
|
|
});
|
|
await expect(bravoRow).toBeVisible({ timeout: 5_000 });
|
|
|
|
const b = await waitForRowInViewport(BRAVO);
|
|
assertInViewport(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<HTMLDivElement>(
|
|
'[data-testid="message-timeline"]',
|
|
);
|
|
const c = document.querySelector<HTMLElement>(
|
|
'[data-testid="message-composer"]',
|
|
);
|
|
if (!t || !c) {
|
|
return { found: false as const };
|
|
}
|
|
const rows = Array.from(
|
|
t.querySelectorAll<HTMLElement>("[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<HTMLDivElement>(
|
|
'[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<HTMLElement>("[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<HTMLDivElement>(
|
|
'[data-testid="message-timeline"]',
|
|
);
|
|
return element && element.scrollHeight > element.clientHeight * 3;
|
|
});
|
|
|
|
await page.waitForFunction(() =>
|
|
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
|
channelName: "general",
|
|
kind: 39005,
|
|
}),
|
|
);
|
|
// 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<HTMLElement>("[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<HTMLElement>("[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");
|
|
await waitForMockChannelHeadReady(
|
|
page,
|
|
"general",
|
|
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
);
|
|
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<HTMLElement>(
|
|
"[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<HTMLElement>(
|
|
'[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");
|
|
await waitForMockChannelHeadReady(
|
|
page,
|
|
"general",
|
|
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
);
|
|
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");
|
|
await waitForMockChannelHeadReady(
|
|
page,
|
|
"general",
|
|
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
);
|
|
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<HTMLElement>(
|
|
'[data-testid="message-timeline"]',
|
|
);
|
|
const spinnerEl = document.querySelector<HTMLElement>(
|
|
'[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<HTMLElement>("[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");
|
|
await waitForMockChannelHeadReady(
|
|
page,
|
|
"general",
|
|
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
);
|
|
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<HTMLElement>("[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<HTMLElement>("[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);
|
|
});
|