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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
366 lines
12 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|