Files
buzz/desktop/tests/e2e/thread-focus-mode.spec.ts
9213090f60 test(desktop): await thread scroll anchor (#3174)
## Why
The focus/split E2E test could capture the thread root before its
programmatic middle-thread scroll had settled, then incorrectly report a
scroll-restoration failure.

## What
- Poll until the requested middle-thread scroll position is applied
- Require the captured anchor to intersect the thread viewport and
differ from the root
- Preserve the existing focus-to-split-to-focus viewport assertions

## Risk Assessment
Low — test-only synchronization change with no production behavior
changes.

## References
- Original failure:
https://github.com/block/buzz/actions/runs/30231271427/job/89870533541
- Buzz thread:
buzz://message?channel=12dd513d-45fd-48ff-80ac-8596d2fcc9d3&id=87ce6024b4bf74bfac2fa75d9f7bbbcc8f8fe2df460afe534152c495929f51ba
- Reproduced confidence: 20 consecutive targeted passes, full spec pass,
`just desktop-ci`, and `just ci`

Generated with Codex

Signed-off-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@buzz.block.builderlab.xyz>
Co-authored-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
2026-08-06 15:04:39 +00:00

297 lines
11 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
async function seedLongThread(page: import("@playwright/test").Page) {
await expect
.poll(() =>
page.evaluate(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
),
)
.toBe(true);
return page.evaluate(() => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: "Focus mode integration thread",
createdAt: 1_700_900_000,
});
if (!root) throw new Error("Failed to seed focus thread root");
for (let index = 0; index < 48; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `Focus reply ${index}: this deliberately wraps across several lines so changing the thread measure causes real layout reflow.`,
parentEventId: root.id,
createdAt: 1_700_900_001 + index,
});
}
return root.id;
});
}
async function scrollToMiddleVisibleMessage(
body: import("@playwright/test").Locator,
threadRootId: string,
): Promise<string> {
let anchorId: string | null = null;
await expect
.poll(async () => {
anchorId = await body.evaluate((element) => {
const maxScrollTop = element.scrollHeight - element.clientHeight;
if (maxScrollTop <= 0) return null;
const targetScrollTop = Math.floor(maxScrollTop * 0.4);
element.scrollTop = targetScrollTop;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
if (Math.abs(element.scrollTop - targetScrollTop) > 1) return null;
const bounds = element.getBoundingClientRect();
const row = Array.from(
element.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.bottom > bounds.top && rect.top < bounds.bottom;
});
return row?.dataset.messageId ?? null;
});
return anchorId !== null && anchorId !== threadRootId;
})
.toBe(true);
if (!anchorId) throw new Error("No visible middle-thread anchor");
return anchorId;
}
/**
* The channel header must own its pixels, not merely be "visible".
*
* Regression guard for the focus-mode launch: a `z-0` on the channel section
* created a stacking context that flattened the header's `z-30` beneath the
* sibling shared header backdrop (also `z-30`), painting the backdrop over the
* name and actions. Neither `toBeVisible()` nor `elementFromPoint` can catch
* this — the backdrop is `pointer-events-none`, so hit-testing skips it. We
* compare CSS paint order directly: walk each element's stacking-context
* chain, find the branches under their common stacking context, and check the
* header's branch wins (higher z-index, or later in DOM order on a tie).
*/
async function expectChannelHeaderUnobscured(
page: import("@playwright/test").Page,
) {
const title = page.getByTestId("chat-title");
await expect(title).toBeVisible();
await expect
.poll(() =>
page.evaluate(() => {
const titleEl = document.querySelector('[data-testid="chat-title"]');
const backdropEl = document.querySelector(
'[data-testid="channel-shared-header-backdrop"]',
);
if (!titleEl) return "missing title";
if (!backdropEl) return "missing backdrop";
const createsStackingContext = (el: Element): boolean => {
const style = getComputedStyle(el);
if (style.position !== "static" && style.zIndex !== "auto")
return true;
if (parseFloat(style.opacity) < 1) return true;
if (style.transform !== "none") return true;
if (style.filter !== "none") return true;
const backdropFilter =
style.backdropFilter ??
(style as unknown as { webkitBackdropFilter?: string })
.webkitBackdropFilter;
if (backdropFilter && backdropFilter !== "none") return true;
if (style.isolation === "isolate") return true;
if (
style.contain.includes("paint") ||
style.contain.includes("strict")
)
return true;
return false;
};
// Chain of stacking-context roots from the element up to <html>.
const stackingChain = (el: Element): Element[] => {
const chain: Element[] = [el];
let current: Element | null = el.parentElement;
while (current) {
if (
createsStackingContext(current) ||
current === document.documentElement
) {
chain.push(current);
}
current = current.parentElement;
}
return chain;
};
const titleChain = stackingChain(titleEl);
const backdropChain = stackingChain(backdropEl);
const common = titleChain.find((el) => backdropChain.includes(el));
if (!common) return "no common stacking context";
// Branch = the child-of-common entry each element paints through.
const titleBranch = titleChain[titleChain.indexOf(common) - 1];
const backdropBranch = backdropChain[backdropChain.indexOf(common) - 1];
if (!titleBranch || !backdropBranch) return "degenerate chain";
const effectiveZ = (el: Element): number => {
const z = getComputedStyle(el).zIndex;
return z === "auto" ? 0 : parseInt(z, 10);
};
const titleZ = effectiveZ(titleBranch);
const backdropZ = effectiveZ(backdropBranch);
if (titleZ !== backdropZ) {
return titleZ > backdropZ ? true : "backdrop paints above header";
}
// Tie: later in DOM order paints on top.
const order = backdropBranch.compareDocumentPosition(titleBranch);
return (order & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
? true
: "backdrop paints above header";
}),
)
.toBe(true);
}
test("focus and split preserve reading context and interaction ownership", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.addInitScript(() => {
localStorage.setItem("buzz.channels.threadViewMode", "focus");
});
await installMockBridge(page, {
managedAgents: [
{
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
name: "alice",
status: "stopped",
},
],
});
await page.goto("/");
const rootId = await seedLongThread(page);
await page.getByTestId("channel-general").click();
await expectChannelHeaderUnobscured(page);
const summary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
);
await expect(summary).toBeVisible();
await summary.click();
const channel = page.getByTestId("channel-drop-zone");
const drawer = page.getByTestId("focus-thread-drawer");
const body = page.getByTestId("message-thread-body");
await expect(drawer).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
Boolean(
document
.querySelector('[data-testid="focus-thread-drawer"]')
?.contains(document.activeElement),
),
),
)
.toBe(true);
await expect(channel).toHaveAttribute("inert", "");
const anchorId = await scrollToMiddleVisibleMessage(body, rootId);
const focusModeToggle = page.getByRole("button", {
name: "Show thread beside channel",
});
await focusModeToggle.hover();
await expect(
page.getByRole("tooltip", { name: "Show thread beside channel" }),
).toBeVisible();
await focusModeToggle.click();
await expect(drawer).toHaveCount(0);
await expect(channel).not.toHaveAttribute("inert", "");
await expectChannelHeaderUnobscured(page);
await expect(page.getByRole("tooltip")).toHaveCount(0);
await expect(page.getByTestId("message-thread-body")).toBeFocused();
await expect(summary).not.toBeFocused();
await expect(
body.locator(`[data-message-id="${anchorId}"]`),
).toBeInViewport();
await expect(
body.locator(`[data-message-id="${anchorId}"]`),
).not.toHaveAttribute("data-highlighted", "true");
// Sidebar background dismissal belongs to the overlay presentation only.
await page
.getByTestId("app-sidebar-scroll-anchor")
.evaluate((element) => (element as HTMLElement).click());
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
const splitModeToggle = page.getByRole("button", { name: "Expand thread" });
await splitModeToggle.focus();
await splitModeToggle.press("Enter");
await expect(drawer).toBeVisible();
await expect(channel).toHaveAttribute("inert", "");
await expect(page.getByTestId("thread-view-mode-toggle")).toBeFocused();
await expect(
body.locator(`[data-message-id="${anchorId}"]`),
).toBeInViewport();
// Focus mode owns Escape even while the rich-text composer and one of its
// nested controls has focus: one press exits the focused thread.
const threadInput = page
.getByTestId("message-thread-panel")
.getByTestId("message-input");
await threadInput.click();
await threadInput.pressSequentially("@al");
await expect(
page
.getByTestId("message-thread-panel")
.getByTestId("mention-autocomplete"),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0);
await expect(channel).not.toHaveAttribute("inert", "");
await summary.click();
await expect(drawer).toBeVisible();
const profileCard = page.getByTestId("sidebar-profile-card");
await profileCard.click({ position: { x: 8, y: 8 } });
await expect(page.getByTestId("profile-popover")).toBeVisible();
await expect(drawer).toBeVisible();
await profileCard.click({ position: { x: 8, y: 8 } });
await expect(page.getByTestId("profile-popover")).toHaveCount(0);
await page
.getByTestId("app-sidebar-scroll-anchor")
.evaluate((element) => (element as HTMLElement).click());
await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0);
await summary.click();
await expect(drawer).toBeVisible();
await page.getByTestId("focus-thread-drawer-scrim").click({
position: { x: 24, y: 200 },
});
await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0);
await expect(channel).not.toHaveAttribute("inert", "");
});
test("narrow threads do not offer an unavailable layout switch", async ({
page,
}) => {
await page.setViewportSize({ width: 860, height: 720 });
await installMockBridge(page);
await page.goto("/");
const rootId = await seedLongThread(page);
await page.getByTestId("channel-general").click();
const summary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
);
await expect(summary).toBeVisible();
await summary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await expect(page.getByTestId("thread-view-mode-toggle")).toHaveCount(0);
});