Files
buzz/desktop/tests/e2e/inbox-live-update.spec.ts
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

1537 lines
61 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import type { RelayEvent } from "../../src/shared/api/types";
/**
* Focused regression suite for inbox-stable-conversation (#inbox-regressions):
*
* 1. Scroll position is NOT reset when a live reply arrives in the currently-
* viewed inbox thread.
* 2. Composer draft value AND focus survive a live reply arrival.
* 3. The `?item=` anchor (selectedEventId) remains the old event after the
* representative advances to a newer sibling (passive arrival).
* 4. The correct row stays selected by conversationId (aria-current on the
* NEW sibling's row) while `?item=` still points to the old anchor.
* 5. scrollIntoView fires exactly zero times on a passive representative
* advance and exactly once on each deliberate anchor change.
* 6. Cold `?item=` recovery via getEventById: old anchor absent from feed but
* present in mockMessages; newer sibling IS in feed; recovery selects the
* sibling row (aria-current), highlights the cold anchor, uses coldRoot.id
* as parentEventId. back/forward restore each anchor with full assertions.
*/
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
type MockWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
mentionPubkeys?: string[];
id?: string;
kind?: number;
extraTags?: string[][];
}) => RelayEvent;
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention" | "needs_action" | "activity" | "agent_activity";
channel_id: string | null;
channel_name: string;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => unknown;
__BUZZ_E2E_REPLACE_MOCK_FEED_ITEM__?: (
oldId: string,
item: {
category: "mention" | "needs_action" | "activity" | "agent_activity";
channel_id: string | null;
channel_name: string;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
},
) => unknown;
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
command: string;
payload: unknown;
}>;
__BUZZ_E2E_SCROLL_INTO_VIEW_COUNT__?: number;
/** When set to an event ID string, get_event calls for that ID are deferred until __BUZZ_E2E_RELEASE_GET_EVENT__ is called. */
__BUZZ_E2E_DEFER_GET_EVENT__?: string | null;
/** Flush all deferred get_event calls; returns the count released. */
__BUZZ_E2E_RELEASE_GET_EVENT__?: () => number;
/** Running count of get_event invocations since installMockBridge. */
__BUZZ_E2E_GET_EVENT_CALL_COUNT__?: number;
};
// ─── helpers ────────────────────────────────────────────────────────────────
async function waitForBridgeReady(page: import("@playwright/test").Page) {
await page.waitForFunction(() => {
const win = window as MockWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function" &&
typeof win.__BUZZ_E2E_REPLACE_MOCK_FEED_ITEM__ === "function" &&
typeof win.__BUZZ_E2E_RELEASE_GET_EVENT__ === "function" &&
Array.isArray(win.__BUZZ_E2E_COMMAND_PAYLOADS__)
);
});
}
/** Installs a scrollIntoView spy that counts calls inside the detail pane. */
async function installScrollSpy(page: import("@playwright/test").Page) {
await page.evaluate(() => {
(window as MockWindow).__BUZZ_E2E_SCROLL_INTO_VIEW_COUNT__ = 0;
const original = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = function (
this: Element,
...args: Parameters<typeof original>
) {
if (this.closest('[data-testid="home-inbox-detail"]')) {
(window as MockWindow).__BUZZ_E2E_SCROLL_INTO_VIEW_COUNT__ =
((window as MockWindow).__BUZZ_E2E_SCROLL_INTO_VIEW_COUNT__ ?? 0) + 1;
}
return original.apply(this, args);
};
});
}
async function getScrollIntoViewCount(page: import("@playwright/test").Page) {
return page.evaluate(
() => (window as MockWindow).__BUZZ_E2E_SCROLL_INTO_VIEW_COUNT__ ?? 0,
);
}
async function resetScrollIntoViewCount(page: import("@playwright/test").Page) {
await page.evaluate(() => {
(window as MockWindow).__BUZZ_E2E_SCROLL_INTO_VIEW_COUNT__ = 0;
});
}
/** Clears the captured command-payload log so the next send can be isolated. */
async function clearCommandPayloads(page: import("@playwright/test").Page) {
await page.evaluate(() => {
const win = window as MockWindow;
if (win.__BUZZ_E2E_COMMAND_PAYLOADS__) {
win.__BUZZ_E2E_COMMAND_PAYLOADS__.length = 0;
}
});
}
function getDetailPane(page: import("@playwright/test").Page) {
return page.getByTestId("home-inbox-detail");
}
function getListPane(page: import("@playwright/test").Page) {
return page.getByTestId("home-inbox-list");
}
async function getScrollTop(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const pane = document.querySelector(
'[data-testid="home-inbox-detail"] [aria-busy]',
) as HTMLElement | null;
return pane?.scrollTop ?? 0;
});
}
/** Returns the last `send_channel_message` payload captured in the command log. */
async function getLastSendPayload(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [];
for (let i = payloads.length - 1; i >= 0; i--) {
if (payloads[i].command === "send_channel_message") {
return payloads[i].payload as {
parentEventId?: string | null;
content?: string;
channelId?: string;
} | null;
}
}
return null;
});
}
/** Returns the current `?item=` URL param value (or null if absent).
* The app uses hash-based routing, so `?item=` lives inside the hash:
* `http://host/#/?item=VALUE` — parse from the hash fragment. */
async function getItemParam(page: import("@playwright/test").Page) {
const url = new URL(page.url());
// hash is e.g. "#/?item=abc" — strip leading "#" then parse as URL path+search
const hashSearch = new URLSearchParams(
url.hash.slice(url.hash.indexOf("?") + 1),
);
return hashSearch.get("item");
}
// ─── seed helpers ─────────────────────────────────────────────────────────
/**
* Seeds a thread (root → anchor) and pushes anchor as the feed representative.
* The anchor is a depth-1 reply to root, so parentId = root.id.
*/
async function seedNestedAnchor(page: import("@playwright/test").Page) {
return page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) throw new Error("Bridge helpers not ready");
const root = emit({
channelName: "general",
content: "Thread root message.",
pubkey: senderPubkey,
id: "a0".repeat(32),
});
const anchor = emit({
channelName: "general",
content: "Nested anchor — the reply that becomes the inbox item.",
parentEventId: root.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "b1".repeat(32),
});
push({
id: anchor.id,
kind: anchor.kind,
pubkey: anchor.pubkey,
content: anchor.content,
created_at: anchor.created_at,
channel_id: channelId,
channel_name: "general",
tags: anchor.tags,
category: "mention",
});
return { root, anchor };
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
}
/**
* Injects a newer same-thread sibling via REPLACE, evicting the old anchor
* from the feed snapshot. Returns the new sibling event.
*/
async function injectNewerSibling(
page: import("@playwright/test").Page,
rootId: string,
anchorId: string,
) {
return page.evaluate(
({ channelId, currentPubkey, senderPubkey, rootEventId, oldAnchorId }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const replace = win.__BUZZ_E2E_REPLACE_MOCK_FEED_ITEM__;
if (!emit || !replace) throw new Error("Bridge helpers not ready");
const sibling = emit({
channelName: "general",
content:
"Newer sibling reply — displaces old anchor as representative.",
parentEventId: rootEventId,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "c2".repeat(32),
});
replace(oldAnchorId, {
id: sibling.id,
kind: sibling.kind,
pubkey: sibling.pubkey,
content: sibling.content,
created_at: sibling.created_at + 1,
channel_id: channelId,
channel_name: "general",
tags: sibling.tags,
category: "mention",
});
return { sibling, oldAnchorId };
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
rootEventId: rootId,
oldAnchorId: anchorId,
},
);
}
// ─── tests ────────────────────────────────────────────────────────────────
test.describe("inbox stable-conversation regressions", () => {
test("scroll and focused draft preserved; new representative row selected when live sibling displaces anchor", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(getListPane(page)).toBeVisible();
await waitForBridgeReady(page);
await installScrollSpy(page);
const { root, anchor } = await seedNestedAnchor(page);
// Select the inbox item — this is the deliberate selection that sets the anchor.
const itemLocator = page.getByTestId(`home-inbox-item-${anchor.id}`);
await itemLocator.click();
const detail = getDetailPane(page);
await expect(detail).toContainText("Nested anchor");
expect(await getItemParam(page)).toBe(anchor.id);
// Add filler replies to make the detail pane scrollable.
await page.evaluate(
({ senderPubkey, rootId }) => {
const emit = (window as MockWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) return;
for (let i = 0; i < 30; i++) {
emit({
channelName: "general",
content: `Filler reply ${i} to make the list long enough to scroll.`,
parentEventId: rootId,
pubkey: senderPubkey,
});
}
},
{ senderPubkey: TEST_IDENTITIES.alice.pubkey, rootId: root.id },
);
await expect(detail).toContainText("Filler reply 0");
// Scroll to a deterministic mid-position: assign scrollTop directly so the
// test does not depend on wheel-event routing or animation timing.
// First scroll to bottom (ensures scrollable content is laid out), then
// pull back up by half to create a non-zero, non-bottom scrollTop.
await page.evaluate(() => {
const pane = document.querySelector(
'[data-testid="home-inbox-detail"] [aria-busy]',
) as HTMLElement | null;
if (!pane) return;
pane.scrollTop = pane.scrollHeight; // go to bottom
const mid = Math.max(1, Math.floor(pane.scrollHeight / 2));
pane.scrollTop = mid; // settle at mid
});
// State-based wait: scroll must be non-zero before proceeding.
await page.waitForFunction(() => {
const pane = document.querySelector(
'[data-testid="home-inbox-detail"] [aria-busy]',
) as HTMLElement | null;
return (pane?.scrollTop ?? 0) > 0;
});
const scrollTopBefore = await getScrollTop(page);
expect(scrollTopBefore).toBeGreaterThan(0);
// Type a draft and confirm focus before the live update.
const composer = detail.getByTestId("message-input");
await composer.click();
await composer.fill("My draft text — must survive the live update");
await expect(composer).toHaveText(
"My draft text — must survive the live update",
);
await expect(composer).toBeFocused();
// Reset scroll spy so only calls from the live update are counted.
await resetScrollIntoViewCount(page);
// Inject newer sibling — passive representative advance.
await injectNewerSibling(page, root.id, anchor.id);
// Wait for the sibling row to appear (proves the feed update landed).
await expect(
page.getByTestId(`home-inbox-item-${"c2".repeat(32)}`),
).toBeVisible();
// ── 1: scroll position preserved ──────────────────────────────────
const scrollTopAfter = await getScrollTop(page);
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2);
// ── 2a: draft text preserved ───────────────────────────────────────
await expect(composer).toHaveText(
"My draft text — must survive the live update",
);
// ── 2b: composer remains focused ──────────────────────────────────
await expect(composer).toBeFocused();
// ── 3: ?item= anchor unchanged (passive arrival keeps old anchor) ──
expect(await getItemParam(page)).toBe(anchor.id);
// ── 4a: zero scrollIntoView on passive representative advance ──────
expect(await getScrollIntoViewCount(page)).toBe(0);
// ── 4b: new sibling row has aria-current (conversation is selected) ─
const siblingRow = page.getByTestId(`home-inbox-item-${"c2".repeat(32)}`);
await expect(siblingRow).toHaveAttribute("aria-current", "true");
// ── 5: old anchor content still visible in detail ──────────────────
await expect(detail).toContainText("Nested anchor");
// ── 6: selected-message highlight on old anchor ────────────────────
const selectedMsg = detail.getByTestId("home-inbox-selected-message");
await expect(selectedMsg).toContainText("Nested anchor");
// ── 7: send → parentEventId = anchor's parentId (root.id) ─────────
await composer.fill("Test reply to verify parentEventId");
await detail.getByRole("button", { name: /send/i }).click();
await page.waitForFunction(() =>
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some(
(p) => p.command === "send_channel_message",
),
);
const lastSend = await getLastSendPayload(page);
expect(lastSend).not.toBeNull();
expect(lastSend?.parentEventId).toBe(root.id);
});
test("deliberate selection recenters exactly once; passive live update does not recenter", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(getListPane(page)).toBeVisible();
await waitForBridgeReady(page);
await installScrollSpy(page);
const { root: rootA, anchor: anchorA } = await seedNestedAnchor(page);
// Seed a second independent thread.
const anchorB = await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) throw new Error("Bridge helpers not ready");
const rootB = emit({
channelName: "general",
content: "Second thread root.",
pubkey: senderPubkey,
id: "d3".repeat(32),
});
const replyB = emit({
channelName: "general",
content: "Second thread reply — a different conversation.",
parentEventId: rootB.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "e4".repeat(32),
});
push({
id: replyB.id,
kind: replyB.kind,
pubkey: replyB.pubkey,
content: replyB.content,
created_at: replyB.created_at,
channel_id: channelId,
channel_name: "general",
tags: replyB.tags,
category: "mention",
});
return replyB;
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
// Deliberate select A — wait for detail pane to show content and URL to update.
await page.getByTestId(`home-inbox-item-${anchorA.id}`).click();
await expect(getDetailPane(page)).toContainText("Nested anchor");
await page.waitForFunction(
({ id }) => {
const h = window.location.hash;
const qs = h.slice(h.indexOf("?") + 1);
return new URLSearchParams(qs).get("item") === id;
},
{ id: anchorA.id },
);
// Reset spy — measure only calls after this point.
await resetScrollIntoViewCount(page);
// Passive live update for A — must NOT recenter.
const { sibling: siblingA } = await injectNewerSibling(
page,
rootA.id,
anchorA.id,
);
// Wait for sibling row to appear (proves the representative advanced).
await expect(
page.getByTestId(`home-inbox-item-${siblingA.id}`),
).toBeVisible();
expect(await getScrollIntoViewCount(page)).toBe(0);
// Sibling row is now aria-current; ?item= unchanged.
await expect(
page.getByTestId(`home-inbox-item-${siblingA.id}`),
).toHaveAttribute("aria-current", "true");
expect(await getItemParam(page)).toBe(anchorA.id);
// Reset spy before deliberate select B.
await resetScrollIntoViewCount(page);
// Deliberate select B — must recenter exactly once.
await page.getByTestId(`home-inbox-item-${anchorB.id}`).click();
await expect(getDetailPane(page)).toContainText("Second thread reply");
await page.waitForFunction(
({ id }) => {
const h = window.location.hash;
const qs = h.slice(h.indexOf("?") + 1);
return new URLSearchParams(qs).get("item") === id;
},
{ id: anchorB.id },
);
expect(await getItemParam(page)).toBe(anchorB.id);
expect(await getScrollIntoViewCount(page)).toBe(1);
// Reset spy before deliberate select back to A (via sibling row).
await resetScrollIntoViewCount(page);
// Deliberate select A again (clicking the sibling row) — recenters once,
// sets ?item= to the sibling (that is the clicked representative).
await page.getByTestId(`home-inbox-item-${siblingA.id}`).click();
await expect(getDetailPane(page)).toContainText("Nested anchor");
await page.waitForFunction(
({ id }) => {
const h = window.location.hash;
const qs = h.slice(h.indexOf("?") + 1);
return new URLSearchParams(qs).get("item") === id;
},
{ id: siblingA.id },
);
// A deliberate click on the sibling representative sets ?item= to sibling.
expect(await getItemParam(page)).toBe(siblingA.id);
expect(await getScrollIntoViewCount(page)).toBe(1);
});
test("cold ?item= recovery: sibling in feed, old anchor in mockMessages only; back/forward restore", async ({
page,
}) => {
// Fixture: coldRoot → coldAnchor + coldSibling (same depth as coldAnchor).
// - All three are emitted into mockMessages (resolvable via get_event).
// - Only coldSibling is pushed to the feed.
// - Cold navigate to ?item=<coldAnchor.id>.
//
// Required (no conditionals):
// URL = coldAnchor.id
// coldSibling row aria-current=true
// home-inbox-selected-message contains coldAnchor content
// send parentEventId = coldRoot.id
//
// Back/forward between two fully resolvable anchors assert URL, current
// representative row, selected-message highlight, and parentEventId.
await installMockBridge(page);
await page.goto("/");
await expect(getListPane(page)).toBeVisible();
await waitForBridgeReady(page);
// ── Seed cold conversation ────────────────────────────────────────
// All events go into mockMessages via emit; only coldSibling enters feed.
const { coldRoot, coldAnchor, coldSibling } = await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) throw new Error("Bridge helpers not ready");
const coldRoot = emit({
channelName: "general",
content: "Cold root.",
pubkey: senderPubkey,
id: "f5".repeat(32),
});
// coldAnchor: depth-1 reply — the cold ?item= target. NOT pushed to feed.
const coldAnchor = emit({
channelName: "general",
content: "Cold anchor — in mockMessages only, not in feed.",
parentEventId: coldRoot.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "f6".repeat(32),
});
// coldSibling: same depth, newer — this IS pushed to feed as the representative.
const coldSibling = emit({
channelName: "general",
content: "Cold sibling — newer representative, present in feed.",
parentEventId: coldRoot.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "f7".repeat(32),
});
push({
id: coldSibling.id,
kind: coldSibling.kind,
pubkey: coldSibling.pubkey,
content: coldSibling.content,
created_at: coldSibling.created_at + 1,
channel_id: channelId,
channel_name: "general",
tags: coldSibling.tags,
category: "mention",
});
return { coldRoot, coldAnchor, coldSibling };
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
// ── Seed a second live anchor for the back/forward partner ────────
const { root: liveRoot, anchor: liveAnchor } = await seedNestedAnchor(page);
// Select the live anchor first — creates a history entry at ?item=<liveAnchor.id>.
await page.getByTestId(`home-inbox-item-${liveAnchor.id}`).click();
await expect(getDetailPane(page)).toContainText("Nested anchor");
expect(await getItemParam(page)).toBe(liveAnchor.id);
// ── Navigate to cold anchor (pushes a second history entry) ──────
// Use real hash assignment so the browser creates a genuine history entry
// (required for page.goBack() / page.goForward() to work) and emits a real
// hashchange event (required for hash-router to pick up the new ?item=).
await page.evaluate(
({ anchorId }) => {
const url = new URL(window.location.href);
const hashPath = url.hash.slice(1) || "/";
const hashUrl = new URL(hashPath, "http://x");
hashUrl.searchParams.set("item", anchorId);
// Assign to location.hash — creates a real history entry + hashchange.
window.location.hash = hashUrl.pathname + hashUrl.search;
},
{ anchorId: coldAnchor.id },
);
// Allow cold recovery (async getEventById) to complete.
// State-based: wait for the sibling row to become aria-current.
await expect(
page.getByTestId(`home-inbox-item-${coldSibling.id}`),
).toHaveAttribute("aria-current", "true");
// ── Cold recovery assertions (unconditional) ──────────────────────
// URL preserved as cold anchor.
expect(await getItemParam(page)).toBe(coldAnchor.id);
// coldSibling row (the feed representative) is aria-current.
await expect(
page.getByTestId(`home-inbox-item-${coldSibling.id}`),
).toHaveAttribute("aria-current", "true");
// Detail pane shows the cold anchor as the highlighted selected message.
const selectedMsgCold = getDetailPane(page).getByTestId(
"home-inbox-selected-message",
);
await expect(selectedMsgCold).toContainText("Cold anchor");
// Send a reply and assert parentEventId = coldRoot.id.
await clearCommandPayloads(page);
const composerCold = getDetailPane(page).getByTestId("message-input");
await composerCold.fill("Reply from cold-recovered anchor");
await getDetailPane(page).getByRole("button", { name: /send/i }).click();
await page.waitForFunction(() =>
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some(
(p) => p.command === "send_channel_message",
),
);
const coldSend = await getLastSendPayload(page);
expect(coldSend).not.toBeNull();
expect(coldSend?.parentEventId).toBe(coldRoot.id);
// ── Go back to live anchor ────────────────────────────────────────
await page.goBack();
await page.waitForFunction(
({ id }) => {
const h = window.location.hash;
const qs = h.slice(h.indexOf("?") + 1);
return new URLSearchParams(qs).get("item") === id;
},
{ id: liveAnchor.id },
);
expect(await getItemParam(page)).toBe(liveAnchor.id);
// liveAnchor's representative row is aria-current.
await expect(
page.getByTestId(`home-inbox-item-${liveAnchor.id}`),
).toHaveAttribute("aria-current", "true");
// coldSibling row must NOT be aria-current.
await expect(
page.getByTestId(`home-inbox-item-${coldSibling.id}`),
).not.toHaveAttribute("aria-current", "true");
// Detail shows the live anchor as the selected message.
await expect(
getDetailPane(page).getByTestId("home-inbox-selected-message"),
).toContainText("Nested anchor");
// Send and assert parentEventId = liveRoot.id.
await clearCommandPayloads(page);
const composerBack = getDetailPane(page).getByTestId("message-input");
await composerBack.fill("Reply after back navigation");
await getDetailPane(page).getByRole("button", { name: /send/i }).click();
await page.waitForFunction(() =>
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some(
(p) => p.command === "send_channel_message",
),
);
const backSend = await getLastSendPayload(page);
expect(backSend).not.toBeNull();
expect(backSend?.parentEventId).toBe(liveRoot.id);
// ── Go forward back to cold anchor ───────────────────────────────
await page.goForward();
await page.waitForFunction(
({ id }) => {
const h = window.location.hash;
const qs = h.slice(h.indexOf("?") + 1);
return new URLSearchParams(qs).get("item") === id;
},
{ id: coldAnchor.id },
);
expect(await getItemParam(page)).toBe(coldAnchor.id);
// coldSibling row is aria-current again.
await expect(
page.getByTestId(`home-inbox-item-${coldSibling.id}`),
).toHaveAttribute("aria-current", "true");
// liveAnchor row must NOT be aria-current.
await expect(
page.getByTestId(`home-inbox-item-${liveAnchor.id}`),
).not.toHaveAttribute("aria-current", "true");
// Detail shows cold anchor as selected message.
await expect(
getDetailPane(page).getByTestId("home-inbox-selected-message"),
).toContainText("Cold anchor");
// Send and assert parentEventId = coldRoot.id again.
await clearCommandPayloads(page);
const composerFwd = getDetailPane(page).getByTestId("message-input");
await composerFwd.fill("Reply after forward navigation");
await getDetailPane(page).getByRole("button", { name: /send/i }).click();
await page.waitForFunction(() =>
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some(
(p) => p.command === "send_channel_message",
),
);
const fwdSend = await getLastSendPayload(page);
expect(fwdSend).not.toBeNull();
expect(fwdSend?.parentEventId).toBe(coldRoot.id);
});
test("cold recovery survives mid-flight feedItems update without being cancelled", async ({
page,
}) => {
// Verifies that the in-flight getEventById promise for a cold anchor is NOT
// cancelled when a concurrent feedItems update (a new live message arriving)
// causes the cold-recovery effect to re-run. The old promise must still
// resolve and populate the recovered item.
//
// Sequence (deterministic via defer/release seam):
// 1. Enable __BUZZ_E2E_DEFER_GET_EVENT__ so get_event is held in-flight.
// 2. Navigate to ?item=<coldAnchor> (absent from feed) — triggers one
// get_event call, which is now queued (provably in-flight).
// 3. Wait for exactly one deferred call to be queued (state-based).
// 4. While get_event is provably still in-flight, push an unrelated feed
// item — re-runs the cold-recovery effect.
// 5. Release the deferred get_event. Recovery must complete.
// 6. Assert all cold-recovery invariants: URL, aria-current, selected
// message highlight, parentEventId — same shape as the primary cold test.
await installMockBridge(page);
await page.goto("/");
await expect(getListPane(page)).toBeVisible();
await waitForBridgeReady(page);
// Seed the cold conversation: root → coldAnchor + coldSibling.
// All three are in mockMessages; only coldSibling is pushed to the feed.
const { coldRoot, coldAnchor, coldSibling } = await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) throw new Error("Bridge helpers not ready");
const coldRoot = emit({
channelName: "general",
content: "Race-test cold root.",
pubkey: senderPubkey,
id: "e8".repeat(32),
});
const coldAnchor = emit({
channelName: "general",
content: "Race-test cold anchor — in mockMessages only.",
parentEventId: coldRoot.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "e9".repeat(32),
});
const coldSibling = emit({
channelName: "general",
content: "Race-test cold sibling — in feed as representative.",
parentEventId: coldRoot.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "ea".repeat(32),
});
push({
id: coldSibling.id,
kind: coldSibling.kind,
pubkey: coldSibling.pubkey,
content: coldSibling.content,
created_at: coldSibling.created_at + 1,
channel_id: channelId,
channel_name: "general",
tags: coldSibling.tags,
category: "mention",
});
return { coldRoot, coldAnchor, coldSibling };
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
// ── Step 1: enable the defer seam for coldAnchor.id only ──────────
// Only get_event calls for coldAnchor.id are held; all other lookups
// (ancestor context, thread roots) continue to resolve immediately.
await page.evaluate(
({ targetId }) => {
(window as MockWindow).__BUZZ_E2E_DEFER_GET_EVENT__ = targetId;
(window as MockWindow).__BUZZ_E2E_GET_EVENT_CALL_COUNT__ = 0;
},
{ targetId: coldAnchor.id },
);
// ── Step 2: navigate to cold anchor — triggers one get_event call ──
// Use real hash assignment for genuine history entry + real hashchange.
await page.evaluate(
({ anchorId }) => {
const url = new URL(window.location.href);
const hashPath = url.hash.slice(1) || "/";
const hashUrl = new URL(hashPath, "http://x");
hashUrl.searchParams.set("item", anchorId);
// Assign to location.hash — creates a real history entry + hashchange.
window.location.hash = hashUrl.pathname + hashUrl.search;
},
{ anchorId: coldAnchor.id },
);
// ── Step 3: wait until exactly one get_event call is queued ────────
// State-based: poll the call counter rather than sleeping.
await page.waitForFunction(
() => (window as MockWindow).__BUZZ_E2E_GET_EVENT_CALL_COUNT__ === 1,
);
// ── Step 4: push an unrelated feed item (re-runs cold-recovery effect)
// This is provably mid-flight because we haven't called release yet.
await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) return;
const unrelated = emit({
channelName: "general",
content:
"Unrelated live item — triggers feedItems update mid-flight.",
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "eb".repeat(32),
});
push({
id: unrelated.id,
kind: unrelated.kind,
pubkey: unrelated.pubkey,
content: unrelated.content,
created_at: unrelated.created_at + 2,
channel_id: channelId,
channel_name: "general",
tags: unrelated.tags,
category: "mention",
});
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
// Confirm the unrelated item appeared (proves feedItems update fired).
await expect(
page.getByTestId(`home-inbox-item-${"eb".repeat(32)}`),
).toBeVisible();
// ── Step 5: release the deferred get_event ─────────────────────────
const released = await page.evaluate(
() => (window as MockWindow).__BUZZ_E2E_RELEASE_GET_EVENT__?.() ?? 0,
);
expect(released).toBe(1);
// Confirm no duplicate requests snuck in during the mid-flight update.
// Count reflects only deferred coldAnchor.id calls (release resets to 0,
// so this is always 0 after release — the pre-release count was 1).
expect(
await page.evaluate(
() => (window as MockWindow).__BUZZ_E2E_GET_EVENT_CALL_COUNT__ ?? 0,
),
).toBe(0); // reset to 0 by release
// ── Step 6: assert cold recovery completed ─────────────────────────
// URL preserved as cold anchor.
await page.waitForFunction(
({ anchorId }) => {
const h = window.location.hash;
const qs = h.slice(h.indexOf("?") + 1);
return new URLSearchParams(qs).get("item") === anchorId;
},
{ anchorId: coldAnchor.id },
);
// coldSibling row (feed representative) is aria-current.
await expect(
page.getByTestId(`home-inbox-item-${coldSibling.id}`),
).toHaveAttribute("aria-current", "true");
// Cold anchor is the highlighted selected message.
await expect(
getDetailPane(page).getByTestId("home-inbox-selected-message"),
).toContainText("Race-test cold anchor");
// Send and assert parentEventId = coldRoot.id.
await clearCommandPayloads(page);
const composer = getDetailPane(page).getByTestId("message-input");
await composer.fill("Reply after race-test cold recovery");
await getDetailPane(page).getByRole("button", { name: /send/i }).click();
await page.waitForFunction(() =>
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some(
(p) => p.command === "send_channel_message",
),
);
const raceSend = await getLastSendPayload(page);
expect(raceSend).not.toBeNull();
expect(raceSend?.parentEventId).toBe(coldRoot.id);
});
test("clicking newest inbox item after fetch-path load snaps to that message, not mid-thread", async ({
page,
}) => {
// Regression: the selected message was visible in `displayMessages` at
// click time (it was the feed representative), but useInboxThreadContext
// fetched older ancestors and prepended them above the viewport, shifting
// scrollTop to mid-thread while the newest message slid below the fold.
//
// Fix: the deliberate-selection center is deferred until
// isThreadContextLoading transitions true → false (fetch settled).
//
// Fixture:
// - fetchRoot + 10 older replies are in mockMessages only (fetch path).
// - fetchNewest (reply to fetchRoot) is pushed to the feed as the
// representative.
// - The thread is long enough that the detail pane requires scrolling.
//
// This test must FAIL at 5536cede5 (center fires before fetch, lands
// mid-thread) and PASS with the fix (center fires after fetch, lands on
// the newest message).
await installMockBridge(page);
await page.goto("/");
await expect(getListPane(page)).toBeVisible();
await waitForBridgeReady(page);
await installScrollSpy(page);
// ── Seed the fetch-path conversation ─────────────────────────────
// All events go into mockMessages via emit; only fetchNewest enters the feed.
const { fetchRoot, fetchNewest } = await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) throw new Error("Bridge helpers not ready");
const fetchRoot = emit({
channelName: "general",
content: "Fetch-path thread root.",
pubkey: senderPubkey,
id: "d0".repeat(32),
});
// 10 older replies — in mockMessages, fetched via the context load.
// Each is tall enough to push the newest message below the fold.
for (let i = 1; i <= 10; i++) {
emit({
channelName: "general",
content: `Fetch-path older reply ${i} — long enough to occupy vertical space in the pane so that the total thread height requires scrolling to see the newest message.`,
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
id: `d${i.toString(16).padStart(1, "0")}`.repeat(32),
});
}
// fetchNewest: the feed representative — the message the user clicks.
const fetchNewest = emit({
channelName: "general",
content:
"Fetch-path newest reply — the one the user clicks in inbox.",
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "df".repeat(32),
});
push({
id: fetchNewest.id,
kind: fetchNewest.kind,
pubkey: fetchNewest.pubkey,
content: fetchNewest.content,
created_at: fetchNewest.created_at + 11,
channel_id: channelId,
channel_name: "general",
tags: fetchNewest.tags,
category: "mention",
});
return { fetchRoot, fetchNewest };
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
// Confirm the feed representative row appeared in the inbox list.
const newestRow = page.getByTestId(`home-inbox-item-${fetchNewest.id}`);
await expect(newestRow).toBeVisible();
// Reset the spy so only the click's center is counted.
await resetScrollIntoViewCount(page);
// ── Click the newest reply row ────────────────────────────────────
await newestRow.click();
const detail = getDetailPane(page);
await expect(detail).toContainText("Fetch-path newest reply");
// Wait for the thread context fetch to complete: the detail pane transitions
// from aria-busy=true → aria-busy=false once isThreadContextLoading settles.
// State-based: poll until aria-busy is no longer "true".
await page.waitForFunction(() => {
const scrollable = document.querySelector(
'[data-testid="home-inbox-detail"] [aria-busy]',
);
return scrollable?.getAttribute("aria-busy") !== "true";
});
// After settle, confirm the older replies are now rendered (fetch landed).
await expect(detail).toContainText("Fetch-path older reply 1");
// ── Assert: selected message is within the pane viewport ─────────
// The selected message (fetchNewest) must be visible inside the scroll
// container — i.e. its bounding rect overlaps with the pane's visible area.
const isSelectedMessageInViewport = await page.evaluate(() => {
const selectedMsg = document.querySelector<HTMLElement>(
'[data-testid="home-inbox-selected-message"]',
);
const pane = document.querySelector<HTMLElement>(
'[data-testid="home-inbox-detail"] [aria-busy]',
);
if (!selectedMsg || !pane) return false;
const paneRect = pane.getBoundingClientRect();
const msgRect = selectedMsg.getBoundingClientRect();
// At least the top half of the element must overlap the pane's visible area.
const msgCenter = msgRect.top + msgRect.height / 2;
return msgCenter >= paneRect.top && msgCenter <= paneRect.bottom;
});
expect(isSelectedMessageInViewport).toBe(true);
// ── Assert: exactly one programmatic scroll fired ─────────────────
expect(await getScrollIntoViewCount(page)).toBe(1);
// ── Bonus: passive live arrivals after settle still trigger zero ──
// Inject a sibling to confirm the scroll-stability invariant is intact.
await page.evaluate(
({ channelId, currentPubkey, senderPubkey, rootId, oldId }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const replace = win.__BUZZ_E2E_REPLACE_MOCK_FEED_ITEM__;
if (!emit || !replace) throw new Error("Bridge helpers not ready");
const passive = emit({
channelName: "general",
content: "Passive live sibling after fetch settled.",
parentEventId: rootId,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "de".repeat(32),
});
replace(oldId, {
id: passive.id,
kind: passive.kind,
pubkey: passive.pubkey,
content: passive.content,
created_at: passive.created_at + 12,
channel_id: channelId,
channel_name: "general",
tags: passive.tags,
category: "mention",
});
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
rootId: fetchRoot.id,
oldId: fetchNewest.id,
},
);
await expect(
page.getByTestId(`home-inbox-item-${"de".repeat(32)}`),
).toBeVisible();
// Count must remain 1 — passive arrival must not trigger another center.
expect(await getScrollIntoViewCount(page)).toBe(1);
});
test("post-settle reaction hydration does not shift the centered selected message out of viewport", async ({
page,
}) => {
// Regression: after the deliberate-selection center fires (once, at
// thread-context settle), reactions fetched for context messages above the
// selected one add ~36px per reacted message to the layout. Without drift
// compensation the selected message slides down/out of center while
// scrollTop stays numerically fixed.
//
// Fix: after the center fires, capture the selected row's viewport offset
// and compensate with scrollBy(0, drift) on every subsequent message-list
// commit. Release on user interaction.
//
// This test must FAIL at 5d20801b9 (no hold compensation, selected message
// drifts out of center on reaction arrival) and PASS with the fix.
//
// Fixture (same shape as test 5):
// - fetchRoot + 10 older replies in mockMessages only (fetch path).
// - fetchNewest (reply to fetchRoot) in feed as representative.
// - Click fetchNewest → center after fetch settle.
// - Emit kind:7 reaction events targeting older replies ABOVE fetchNewest.
// - Assert selected message stays in viewport; spy count stays 1.
await installMockBridge(page);
await page.goto("/");
await expect(getListPane(page)).toBeVisible();
await waitForBridgeReady(page);
await installScrollSpy(page);
// ── Seed the fetch-path conversation ─────────────────────────────
const { fetchNewest, olderReplyIds } = await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) throw new Error("Bridge helpers not ready");
const fetchRoot = emit({
channelName: "general",
content: "Reaction-drift test root.",
pubkey: senderPubkey,
id: "e0".repeat(32),
});
// 10 older replies — in mockMessages, prepended above fetchNewest
// after the context fetch. Reactions targeting these cause drift.
const olderReplyIds: string[] = [];
for (let i = 1; i <= 10; i++) {
const reply = emit({
channelName: "general",
content: `Reaction-drift older reply ${i} — long enough to occupy vertical space so reactions adding height push the selected message out of center when uncompensated.`,
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
id: `e${i.toString(16).padStart(1, "0")}`.repeat(32),
});
olderReplyIds.push(reply.id);
}
const fetchNewest = emit({
channelName: "general",
content:
"Reaction-drift selected reply — the one the user clicks; must hold position after reactions arrive.",
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "ef".repeat(32),
});
// Later replies ensure the selected row has enough content below it to
// be truly centered rather than clamped at the scroll container floor.
for (let i = 1; i <= 5; i++) {
emit({
channelName: "general",
content: `Reaction-drift later reply ${i} — provides content below the selected message for a non-clamped center.`,
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
id: `f${i.toString(16).padStart(1, "0")}`.repeat(32),
});
}
push({
id: fetchNewest.id,
kind: fetchNewest.kind,
pubkey: fetchNewest.pubkey,
content: fetchNewest.content,
created_at: fetchNewest.created_at + 11,
channel_id: channelId,
channel_name: "general",
tags: fetchNewest.tags,
category: "mention",
});
return { fetchNewest, olderReplyIds };
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
const newestRow = page.getByTestId(`home-inbox-item-${fetchNewest.id}`);
await expect(newestRow).toBeVisible();
// Reset the spy so only the center from the click is counted.
await resetScrollIntoViewCount(page);
// ── Click the newest reply row ────────────────────────────────────
await newestRow.click();
const detail = getDetailPane(page);
await expect(detail).toContainText("Reaction-drift selected reply");
// Wait for the thread context fetch to settle.
await page.waitForFunction(() => {
const scrollable = document.querySelector(
'[data-testid="home-inbox-detail"] [aria-busy]',
);
return scrollable?.getAttribute("aria-busy") !== "true";
});
// Older replies are now rendered (fetch landed).
await expect(detail).toContainText("Reaction-drift older reply 1");
// Record the deliberate center result before reactions land. Later replies
// provide enough space below this row that `scrollIntoView({ block: "center" })`
// can center it rather than clamping at the pane bottom.
const msgCenterOffsetBeforeReactions = await page.evaluate(() => {
const selectedMsg = document.querySelector<HTMLElement>(
'[data-testid="home-inbox-selected-message"]',
);
const pane = document.querySelector<HTMLElement>(
'[data-testid="home-inbox-detail"] [aria-busy]',
);
if (!selectedMsg || !pane) return null;
const paneRect = pane.getBoundingClientRect();
const msgRect = selectedMsg.getBoundingClientRect();
return (
msgRect.top + msgRect.height / 2 - (paneRect.top + paneRect.height / 2)
);
});
expect(msgCenterOffsetBeforeReactions).not.toBeNull();
expect(await getScrollIntoViewCount(page)).toBe(1);
// ── Emit late reactions targeting messages ABOVE fetchNewest ──────
// These simulate the post-settle reaction hydration from
// useInboxThreadContext's fetchAuxEventsByReference call. kind:7 events
// with ["e", <olderReplyId>] tags arrive via the channel live subscription
// and feed into channelMessages → reactionEvents → messages prop, causing
// height growth above the selected row. Targeting 5 of the 10 older
// replies produces ~5 × 36px ≈ 180px of drift when uncompensated.
await page.evaluate(
({ senderPubkey, targetIds }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) return;
for (const targetId of targetIds) {
emit({
channelName: "general",
content: "+",
pubkey: senderPubkey,
kind: 7,
extraTags: [["e", targetId]],
});
}
},
{
senderPubkey: TEST_IDENTITIES.bob.pubkey,
// React to the first 5 older replies (all above fetchNewest).
targetIds: olderReplyIds.slice(0, 5),
},
);
// Wait for reactions to appear: at least one reactions container becomes visible.
// The reactions update `messages`, which re-renders InboxMessageRow with
// MessageReactions containers, growing each row that received a reaction.
await page.waitForFunction(() => {
return (
(document
.querySelector('[data-testid="home-inbox-detail"]')
?.querySelectorAll('[data-testid="message-reactions"]').length ?? 0) >
0
);
});
// ── Assert: selected message retains its post-center viewport offset ─
// With the fix: the layout effect fires on messages change, measures drift,
// and calls scrollBy(0, drift) to preserve fetchNewest's actual centered
// position. Without the fix it drifts ~180px down (5 reactions × 36px).
const msgCenterOffsetAfterReactions = await page.evaluate(() => {
const selectedMsg = document.querySelector<HTMLElement>(
'[data-testid="home-inbox-selected-message"]',
);
const pane = document.querySelector<HTMLElement>(
'[data-testid="home-inbox-detail"] [aria-busy]',
);
if (!selectedMsg || !pane) return null;
const paneRect = pane.getBoundingClientRect();
const msgRect = selectedMsg.getBoundingClientRect();
const msgCenter = msgRect.top + msgRect.height / 2;
const paneCenter = paneRect.top + paneRect.height / 2;
// Positive = selected message is below pane center. Without the fix
// this grows by ~180px as reactions add height above the anchor.
return msgCenter - paneCenter;
});
expect(msgCenterOffsetAfterReactions).not.toBeNull();
// With the fix the offset stays where the deliberate center placed it.
// Without the fix, late reaction height moves it down by ~180px.
expect(
Math.abs(
(msgCenterOffsetAfterReactions ?? Number.MAX_SAFE_INTEGER) -
(msgCenterOffsetBeforeReactions ?? 0),
),
).toBeLessThan(30);
// ── Assert: scrollIntoView spy count is still 1 ───────────────────
// Drift compensation uses scrollBy — must NOT call scrollIntoView again.
expect(await getScrollIntoViewCount(page)).toBe(1);
});
test("direct container scroll releases the hold before late reaction hydration", async ({
page,
}) => {
// A direct scroll (such as a scrollbar drag) must release the post-center
// anchor hold before late reaction hydration. The test asserts the user
// action is recognized without wheel, touch, or key events.
//
// Fixture (same shape as test 6):
// - fetchRoot + 10 older replies in mockMessages only (fetch path).
// - fetchNewest (reply to fetchRoot) in feed as representative.
// - Click fetchNewest → center after fetch settle.
// - Emit kind:7 reaction events targeting older replies ABOVE fetchNewest.
// - Assert selected message stays in viewport; spy count stays 1.
await installMockBridge(page);
await page.goto("/");
await expect(getListPane(page)).toBeVisible();
await waitForBridgeReady(page);
await installScrollSpy(page);
// ── Seed the fetch-path conversation ─────────────────────────────
const { fetchNewest, olderReplyIds } = await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) throw new Error("Bridge helpers not ready");
const fetchRoot = emit({
channelName: "general",
content: "Reaction-drift test root.",
pubkey: senderPubkey,
id: "e0".repeat(32),
});
// 10 older replies — in mockMessages, prepended above fetchNewest
// after the context fetch. Reactions targeting these cause drift.
const olderReplyIds: string[] = [];
for (let i = 1; i <= 10; i++) {
const reply = emit({
channelName: "general",
content: `Reaction-drift older reply ${i} — long enough to occupy vertical space so reactions adding height push the selected message out of center when uncompensated.`,
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
id: `e${i.toString(16).padStart(1, "0")}`.repeat(32),
});
olderReplyIds.push(reply.id);
}
const fetchNewest = emit({
channelName: "general",
content:
"Reaction-drift selected reply — the one the user clicks; must hold position after reactions arrive.",
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "ef".repeat(32),
});
// Later replies ensure the selected row has enough content below it to
// be truly centered rather than clamped at the scroll container floor.
for (let i = 1; i <= 5; i++) {
emit({
channelName: "general",
content: `Reaction-drift later reply ${i} — provides content below the selected message for a non-clamped center.`,
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
id: `f${i.toString(16).padStart(1, "0")}`.repeat(32),
});
}
push({
id: fetchNewest.id,
kind: fetchNewest.kind,
pubkey: fetchNewest.pubkey,
content: fetchNewest.content,
created_at: fetchNewest.created_at + 11,
channel_id: channelId,
channel_name: "general",
tags: fetchNewest.tags,
category: "mention",
});
return { fetchNewest, olderReplyIds };
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
const newestRow = page.getByTestId(`home-inbox-item-${fetchNewest.id}`);
await expect(newestRow).toBeVisible();
// Reset the spy so only the center from the click is counted.
await resetScrollIntoViewCount(page);
// ── Click the newest reply row ────────────────────────────────────
await newestRow.click();
const detail = getDetailPane(page);
await expect(detail).toContainText("Reaction-drift selected reply");
// Wait for the thread context fetch to settle.
await page.waitForFunction(() => {
const scrollable = document.querySelector(
'[data-testid="home-inbox-detail"] [aria-busy]',
);
return scrollable?.getAttribute("aria-busy") !== "true";
});
// Older replies are now rendered (fetch landed).
await expect(detail).toContainText("Reaction-drift older reply 1");
expect(await getScrollIntoViewCount(page)).toBe(1);
// Simulate a scrollbar drag: change the actual scroll container directly
// and dispatch only `scroll`, without wheel/touch/key input. This must
// release the post-center hold before late layout growth arrives.
const scrollTopAfterDirectScroll = await page.evaluate(() => {
const pane = document.querySelector<HTMLElement>(
'[data-testid="home-inbox-detail"] [aria-busy]',
);
if (!pane) return null;
const originalScrollBy = pane.scrollBy.bind(pane);
let scrollByCalls = 0;
pane.scrollBy = (...args) => {
scrollByCalls += 1;
originalScrollBy(...args);
};
(window as Window & { __scrollByCalls?: () => number }).__scrollByCalls =
() => scrollByCalls;
const scrollTopBefore = pane.scrollTop;
pane.scrollTop = Math.min(
pane.scrollHeight - pane.clientHeight,
pane.scrollTop + 220,
);
if (pane.scrollTop === scrollTopBefore) return null;
pane.dispatchEvent(new Event("scroll"));
return pane.scrollTop;
});
expect(scrollTopAfterDirectScroll).not.toBeNull();
await page.evaluate(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
// ── Emit late reactions targeting messages ABOVE fetchNewest ──────
// These simulate the post-settle reaction hydration from
// useInboxThreadContext's fetchAuxEventsByReference call. kind:7 events
// with ["e", <olderReplyId>] tags arrive via the channel live subscription
// and feed into channelMessages → reactionEvents → messages prop, causing
// height growth above the selected row. Targeting 5 of the 10 older
// replies produces ~5 × 36px ≈ 180px of drift when uncompensated.
await page.evaluate(
({ senderPubkey, targetIds }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) return;
for (const targetId of targetIds) {
emit({
channelName: "general",
content: "+",
pubkey: senderPubkey,
kind: 7,
extraTags: [["e", targetId]],
});
}
},
{
senderPubkey: TEST_IDENTITIES.bob.pubkey,
// React to five rows above the selected anchor.
targetIds: olderReplyIds.slice(0, 5),
},
);
// Wait for reactions to appear: at least one reactions container becomes visible.
// The reactions update `messages`, which re-renders InboxMessageRow with
// MessageReactions containers, growing each row that received a reaction.
await page.waitForFunction(() => {
return (
(document
.querySelector('[data-testid="home-inbox-detail"]')
?.querySelectorAll('[data-testid="message-reactions"]').length ?? 0) >
0
);
});
await page.evaluate(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
// The direct scroll releases the hold, so late layout growth must not run
// the compensating `scrollBy` writer and pull the reader after they moved.
expect(
await page.evaluate(
() =>
(
window as Window & { __scrollByCalls?: () => number }
).__scrollByCalls?.() ?? null,
),
).toBe(0);
// ── Assert: scrollIntoView spy count is still 1 ───────────────────
// Drift compensation uses scrollBy — must NOT call scrollIntoView again.
expect(await getScrollIntoViewCount(page)).toBe(1);
});
});