Files
buzz/desktop/tests/e2e/channel-composer-overflow.spec.ts
T
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

366 lines
12 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
// The channel and thread composers float over their conversation scrollers.
// When the conversation is scrolled up, later rows pass underneath the
// composer overlay — the overlay must mask them (gradient fade + solid
// bottom strip, mirroring the inbox treatment from PR #2143) instead of
// letting them bleed out below the composer box.
const CHANNEL = "general";
const TYPING_KIND = 20002;
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
kind?: number,
) {
await expect
.poll(() =>
page.evaluate(
({ channelName, kind }) =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName,
kind,
}) ?? false,
{ channelName, kind },
),
)
.toBe(true);
}
async function emit(
page: import("@playwright/test").Page,
content: string,
parentEventId: string | null = null,
) {
const event = await page.evaluate(
(payload) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: payload.channel,
content: payload.content,
parentEventId: payload.parentEventId,
}),
{ channel: CHANNEL, content, parentEventId },
);
if (!event) throw new Error("mock message emitter is not installed");
return event as { created_at: number; id: string };
}
type ComposerDockGeometry = {
composerBottom: number;
composerLeft: number;
composerRight: number;
composerTop: number;
dockHeight: number;
dockTop: number;
};
async function composerDockGeometry(
overlay: import("@playwright/test").Locator,
): Promise<ComposerDockGeometry> {
return overlay.evaluate((element) => {
const composer = element.querySelector<HTMLElement>(
'[data-testid="message-composer"]',
);
const dock = element.querySelector<HTMLElement>(".composer-dock");
if (!composer || !dock) throw new Error("Missing composer dock geometry");
const composerRect = composer.getBoundingClientRect();
const dockRect = dock.getBoundingClientRect();
return {
composerBottom: composerRect.bottom,
composerLeft: composerRect.left,
composerRight: composerRect.right,
composerTop: composerRect.top,
dockHeight: dockRect.height,
dockTop: dockRect.top,
};
});
}
async function expectOverlayBottomMask(
overlay: import("@playwright/test").Locator,
) {
const hasMask = await overlay.evaluate((element) => {
const after = getComputedStyle(element, "::after");
const before = getComputedStyle(element, "::before");
const composer = element.querySelector<HTMLElement>(
'[data-testid="message-composer"]',
);
const cornerMaskContainer = element.querySelector<HTMLElement>(
".composer-overlay-corner-masks",
);
if (!composer || !cornerMaskContainer) return false;
const overlayRect = element.getBoundingClientRect();
const composerRect = composer.getBoundingClientRect();
const leftMask = getComputedStyle(cornerMaskContainer, "::before");
const rightMask = getComputedStyle(cornerMaskContainer, "::after");
const tolerance = 0.5;
return (
after.content !== "none" &&
before.content !== "none" &&
leftMask.maskImage !== "none" &&
rightMask.maskImage !== "none" &&
Math.abs(
composerRect.left - overlayRect.left - Number.parseFloat(leftMask.left),
) <= tolerance &&
Math.abs(
overlayRect.right -
composerRect.right -
Number.parseFloat(rightMask.right),
) <= tolerance &&
Math.abs(
overlayRect.bottom -
composerRect.bottom -
Number.parseFloat(leftMask.bottom),
) <= tolerance &&
leftMask.bottom === rightMask.bottom
);
});
expect(hasMask).toBe(true);
}
test.describe("composer overlays mask scrolled content", () => {
test("channel timeline rows fade out behind the composer", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
await waitForMockLiveSubscription(page, CHANNEL);
for (let i = 0; i < 20; i++) {
await emit(
page,
`Channel filler ${i} — enough text to occupy vertical space so the conversation scrolls and rows pass behind the composer overlay.`,
);
}
await page.waitForTimeout(400);
// Scroll the conversation up so trailing rows sit behind the overlay.
await page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(
'[data-buzz-conversation-scroll="true"]',
);
if (!scroller) throw new Error("Missing conversation scroll container");
scroller.scrollTop = Math.max(
0,
scroller.scrollHeight - scroller.clientHeight - 220,
);
});
await page.waitForTimeout(300);
await waitForAnimations(page);
await page.screenshot({
path: "test-results/channel-overflow/channel-composer.png",
clip: { x: 300, y: 720 - 280, width: 980, height: 280 },
});
await expectOverlayBottomMask(page.getByTestId("channel-composer-overlay"));
});
test("activity trades composer height without moving the dock", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await waitForMockLiveSubscription(page, CHANNEL);
await waitForMockLiveSubscription(page, CHANNEL, TYPING_KIND);
const overlay = page.getByTestId("channel-composer-overlay");
const input = overlay.getByTestId("message-input");
await input.fill(
"A multiline draft that grows the composer.\nSecond line keeps the natural-height path active.",
);
await waitForAnimations(page);
const quiet = await composerDockGeometry(overlay);
await page.evaluate((channelName) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({ channelName });
}, CHANNEL);
await expect(
overlay.getByTestId("channel-composer-activity-row"),
).toBeVisible();
await waitForAnimations(page);
const active = await composerDockGeometry(overlay);
expect(Math.abs(active.dockTop - quiet.dockTop)).toBeLessThanOrEqual(0.5);
expect(Math.abs(active.dockHeight - quiet.dockHeight)).toBeLessThanOrEqual(
0.5,
);
expect(
Math.abs(active.composerTop - quiet.composerTop),
).toBeLessThanOrEqual(0.5);
expect(active.composerBottom).toBeLessThan(quiet.composerBottom - 8);
});
test("reduced motion removes dock geometry transitions", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
const dock = page
.getByTestId("channel-composer-overlay")
.locator(".composer-dock");
const spacer = dock.locator(".composer-dock-quiet-spacer");
await expect(dock).toHaveCSS("transition-duration", "0s");
await expect(spacer).toHaveCSS("transition-duration", "0s");
});
test("bottom-pinned messages clear a growing composer", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
await waitForMockLiveSubscription(page, CHANNEL);
let newestMessageId = "";
for (let i = 0; i < 20; i++) {
newestMessageId = (await emit(page, `Bottom-clearance message ${i}`)).id;
}
await page.waitForTimeout(300);
await page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(
'[data-buzz-conversation-scroll="true"]',
);
if (!scroller) throw new Error("Missing conversation scroll container");
scroller.scrollTop = scroller.scrollHeight;
});
await page
.getByTestId("message-input")
.fill(
"A long draft grows the composer and must keep the newest message above it.\nSecond line.\nThird line.",
);
await waitForAnimations(page);
const clearsComposer = await page.evaluate((messageId) => {
const composer = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"] [data-testid="message-composer"]',
);
const newestRow = document.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(messageId)}"]`,
);
if (!composer || !newestRow) return false;
return (
newestRow.getBoundingClientRect().bottom <=
composer.getBoundingClientRect().top
);
}, newestMessageId);
expect(clearsComposer).toBe(true);
await page.setViewportSize({ width: 1280, height: 620 });
await expect
.poll(() =>
page.evaluate((messageId) => {
const composer = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"] [data-testid="message-composer"]',
);
const newestRow = document.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(messageId)}"]`,
);
return Boolean(
composer &&
newestRow &&
newestRow.getBoundingClientRect().bottom <=
composer.getBoundingClientRect().top,
);
}, newestMessageId),
)
.toBe(true);
});
test("thread panel replies fade out behind the reply composer", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
await waitForMockLiveSubscription(page, CHANNEL);
const root = await emit(page, "Thread root — the discussion begins here.");
for (let i = 0; i < 20; i++) {
await emit(
page,
`Thread reply ${i} — enough text to occupy vertical space so the thread body scrolls and replies pass behind the reply composer.`,
root.id,
);
}
await page.waitForTimeout(400);
const summary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${root.id}"]`,
);
await expect(summary).toBeVisible();
await summary.click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await waitForAnimations(page);
// Scroll the thread body up so trailing replies sit behind the overlay.
await page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(
'[data-testid="message-thread-body"]',
);
if (!scroller) throw new Error("Missing thread body scroll container");
scroller.scrollTop = Math.max(
0,
scroller.scrollHeight - scroller.clientHeight - 220,
);
});
await page.waitForTimeout(300);
await waitForAnimations(page);
await page.screenshot({
path: "test-results/channel-overflow/thread-composer.png",
clip: { x: 1280 - 560, y: 720 - 300, width: 560, height: 300 },
});
await expectOverlayBottomMask(page.getByTestId("thread-composer-overlay"));
await waitForMockLiveSubscription(page, CHANNEL, TYPING_KIND);
await page.evaluate(
({ channelName, createdAt, pubkey, threadHeadId }) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName,
createdAt,
pubkey,
threadHeadId,
});
},
{
channelName: CHANNEL,
createdAt: root.created_at + 1,
pubkey: TEST_IDENTITIES.bob.pubkey,
threadHeadId: root.id,
},
);
const threadOverlay = page.getByTestId("thread-composer-overlay");
const activityRow = threadOverlay.getByTestId("message-typing-indicator");
await expect(activityRow).toBeVisible();
await waitForAnimations(page);
const [composerBounds, activityBounds] = await Promise.all([
threadOverlay.getByTestId("message-composer").boundingBox(),
activityRow.boundingBox(),
]);
expect(composerBounds).not.toBeNull();
expect(activityBounds).not.toBeNull();
expect(activityBounds?.x ?? 0).toBeGreaterThanOrEqual(
(composerBounds?.x ?? 0) - 0.5,
);
expect(
(activityBounds?.x ?? 0) + (activityBounds?.width ?? 0),
).toBeLessThanOrEqual(
(composerBounds?.x ?? 0) + (composerBounds?.width ?? 0) + 0.5,
);
});
});