Files
buzz/desktop/tests/e2e/stream.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

535 lines
17 KiB
TypeScript

import { expect, test, type Browser, type Page } from "@playwright/test";
import {
installRelayBridge,
openChannelBrowser,
openCreateChannelDialog,
TEST_IDENTITIES,
} from "../helpers/bridge";
import { assertRelaySeeded } from "../helpers/seed";
const isCi = Boolean(process.env.CI);
const relaySeedHookTimeoutMs = isCi ? 90_000 : 30_000;
async function expectTimelineToContain(page: Page, text: string) {
await expect(page.getByTestId("message-timeline")).toContainText(text);
}
async function getTimelineMetrics(page: Page) {
return page.getByTestId("message-timeline").evaluate((element) => {
const timeline = element as HTMLDivElement;
const composer = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"]',
);
const composerHeight = composer?.getBoundingClientRect().height ?? 0;
return {
clientHeight: timeline.clientHeight,
scrollHeight: timeline.scrollHeight,
scrollTop: timeline.scrollTop,
composerHeight,
// The virtualized timeline reserves a trailing spacer equal to the
// overlaid composer. Reaching the visual tail therefore leaves that
// spacer below the last row rather than setting the raw DOM distance to 0.
distanceFromBottom:
timeline.scrollHeight -
timeline.clientHeight -
timeline.scrollTop -
composerHeight,
};
});
}
async function seedTimelineHistory(
senderPage: Page,
channelName: string,
prefix: string,
) {
const messages = Array.from(
{ length: 24 },
(_, index) => `${prefix} seed ${index}`,
);
for (const content of messages) {
await sendChannelMessage(senderPage, { channelName, content });
}
const lastSeed = messages.at(-1);
if (!lastSeed) throw new Error("Timeline seed set is empty.");
return lastSeed;
}
async function createAndJoinSharedStream(
ownerPage: Page,
memberPage: Page,
channelName: string,
seed?: { prefix: string; minDistance?: number },
) {
await openCreateChannelDialog(ownerPage);
await ownerPage.getByTestId("create-channel-name").fill(channelName);
await ownerPage.getByTestId("create-channel-submit").click();
await expect(ownerPage.getByTestId("stream-list")).toContainText(channelName);
await expect(ownerPage.getByTestId("chat-title")).toHaveText(channelName);
await expect(ownerPage.getByTestId("message-input")).toBeEnabled();
const lastSeed = seed
? await seedTimelineHistory(ownerPage, channelName, seed.prefix)
: null;
await openChannelBrowser(memberPage);
await expect(memberPage.getByTestId("channel-browser-dialog")).toBeVisible();
await memberPage
.getByTestId(`browse-channel-${channelName}`)
.getByRole("button", { name: "Join" })
.click();
await expect(memberPage.getByTestId("chat-title")).toHaveText(channelName);
await expect(memberPage.getByTestId("stream-list")).toContainText(
channelName,
);
await expect(memberPage.getByTestId("message-input")).toBeEnabled();
if (lastSeed) {
await expectTimelineToContain(memberPage, lastSeed);
const metrics = await getTimelineMetrics(memberPage);
expect(metrics.scrollHeight).toBeGreaterThan(
metrics.clientHeight +
metrics.composerHeight +
(seed?.minDistance ?? 160),
);
}
}
async function sendChannelMessage(
page: Page,
{
channelName,
content,
mentionPubkeys,
}: {
channelName: string;
content: string;
mentionPubkeys?: string[];
},
) {
await page.waitForFunction(
() => {
const tauriWindow = window as Window & {
__TAURI_INTERNALS__?: { invoke?: unknown };
};
return typeof tauriWindow.__TAURI_INTERNALS__?.invoke === "function";
},
null,
{ timeout: 5_000 },
);
await page.evaluate(
async ({ channelName: targetChannelName, content, mentionPubkeys }) => {
const tauriWindow = window as Window & {
__TAURI_INTERNALS__?: {
invoke: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Tauri invoke bridge is unavailable.");
}
const channels = (await invoke("get_channels")) as Array<{
id: string;
name: string;
}>;
const channel = channels.find(({ name }) => name === targetChannelName);
if (!channel) {
throw new Error(`Channel not found: ${targetChannelName}`);
}
await invoke("send_channel_message", {
channelId: channel.id,
content,
kind: null,
mediaTags: null,
mentionPubkeys: mentionPubkeys ?? null,
parentEventId: null,
});
},
{ channelName, content, mentionPubkeys },
);
}
async function scrollTimelineAwayFromBottom(page: Page, minDistance = 160) {
const timeline = page.getByTestId("message-timeline");
await timeline.hover();
for (let attempt = 0; attempt < 8; attempt += 1) {
await page.mouse.wheel(0, -800);
const metrics = await getTimelineMetrics(page);
if (metrics.distanceFromBottom > minDistance) {
// The DOM scroll position is now off the bottom, but the timeline's
// `onScroll` handler updates the React "away from bottom" anchor state
// asynchronously. If a new message lands before that commit, the stale
// at-bottom anchor swallows it and the unread counter never increments.
// The scroll-to-latest pill only mounts once that state has committed,
// so waiting for it guarantees the anchor is in the away-from-bottom
// branch before callers send the message they expect to be counted.
await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
return metrics.distanceFromBottom;
}
}
throw new Error("Failed to scroll the timeline away from the bottom.");
}
test.beforeAll(async () => {
test.setTimeout(relaySeedHookTimeoutMs);
await assertRelaySeeded();
});
test("loads channels from the relay", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await expect(page.getByTestId("stream-list")).toContainText("general");
await expect(page.getByTestId("stream-list")).toContainText("random");
await expect(page.getByTestId("forum-list")).toContainText("watercooler");
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
});
test("loads the home feed from the relay", async ({ browser }) => {
const message = `Relay home mention ${Date.now()}`;
const targetContext = await browser.newContext();
const senderContext = await browser.newContext();
const page = await targetContext.newPage();
const senderPage = await senderContext.newPage();
try {
await installRelayBridge(page, "tyler");
await installRelayBridge(senderPage, "alice");
await page.goto("/");
await senderPage.goto("/");
await expect(page.getByTestId("home-inbox")).toBeVisible();
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await sendChannelMessage(senderPage, {
channelName: "general",
content: message,
mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey],
});
await expect(page.getByTestId("home-inbox-list")).toContainText(message);
await expect(page.getByTestId("home-inbox-detail")).toBeVisible();
} finally {
await targetContext.close();
await senderContext.close();
}
});
test("shows sent inbox replies immediately in the inbox detail pane", async ({
browser,
}) => {
const message = `Relay inbox reply target ${Date.now()}`;
const reply = `Inbox reply ${Date.now()}`;
const targetContext = await browser.newContext();
const senderContext = await browser.newContext();
const page = await targetContext.newPage();
const senderPage = await senderContext.newPage();
try {
await installRelayBridge(page, "tyler");
await installRelayBridge(senderPage, "alice");
await page.goto("/");
await senderPage.goto("/");
await sendChannelMessage(senderPage, {
channelName: "general",
content: message,
mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey],
});
await page.getByTestId("home-inbox-list").getByText(message).click();
await expect(page.getByTestId("home-inbox-detail")).toBeVisible();
await expect(page.getByTestId("message-input")).toBeEnabled();
await page.getByTestId("message-input").fill(reply);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("home-inbox-detail")).toContainText(reply);
} finally {
await targetContext.close();
await senderContext.close();
}
});
test("creates a relay-backed stream", async ({ page }) => {
const channelName = `desktop-e2e-${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")
.fill("Created from Playwright");
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("sends a message through the real relay", async ({ page }) => {
const message = `Integration message ${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expectTimelineToContain(page, message);
});
test("delivers a message to a second browser context in real time", async ({
browser,
}: {
browser: Browser;
}) => {
const channelName = `realtime-shared-${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
const message = `Realtime message ${Date.now()}`;
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await pageTwo.goto("/");
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
await pageOne.getByTestId("message-input").fill(message);
await pageOne.getByTestId("send-message").click();
await expectTimelineToContain(pageTwo, message);
} finally {
await contextOne.close();
await contextTwo.close();
}
});
test("stays pinned to the latest message when new messages arrive at the bottom", async ({
browser,
}: {
browser: Browser;
}) => {
test.slow();
const channelName = `pinned-bottom-${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
const prefix = `Pinned scroll ${Date.now()}`;
const incomingMessage = `${prefix} incoming`;
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await pageTwo.goto("/");
await createAndJoinSharedStream(pageOne, pageTwo, channelName, { prefix });
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
await sendChannelMessage(pageOne, {
channelName,
content: incomingMessage,
});
await expectTimelineToContain(pageTwo, incomingMessage);
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toHaveCount(
0,
);
} finally {
await contextOne.close();
await contextTwo.close();
}
});
test("stays pinned after you send a message and a remote reply arrives right after", async ({
browser,
}: {
browser: Browser;
}) => {
test.slow();
const channelName = `reply-shared-${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
const prefix = `Reply after send ${Date.now()}`;
const localMessage = `${prefix} local`;
const incomingMessage = `${prefix} incoming`;
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await pageTwo.goto("/");
await createAndJoinSharedStream(pageOne, pageTwo, channelName, { prefix });
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
await pageTwo.getByTestId("message-input").fill(localMessage);
await pageTwo.getByTestId("send-message").click();
await expectTimelineToContain(pageTwo, localMessage);
await sendChannelMessage(pageOne, {
channelName,
content: incomingMessage,
});
await expectTimelineToContain(pageTwo, incomingMessage);
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toHaveCount(
0,
);
} finally {
await contextOne.close();
await contextTwo.close();
}
});
test("keeps bottom-pinned scrolling after the composer grows", async ({
browser,
}: {
browser: Browser;
}) => {
test.slow();
const channelName = `composer-shared-${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
const prefix = `Composer pinned ${Date.now()}`;
const incomingMessage = `${prefix} incoming`;
const receiverInput = pageTwo.getByTestId("message-input");
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await pageTwo.goto("/");
await createAndJoinSharedStream(pageOne, pageTwo, channelName, { prefix });
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
await receiverInput.fill("Composer pinned line one");
await receiverInput.press("Enter");
await receiverInput.type("Composer pinned line two");
await receiverInput.press("Enter");
await receiverInput.type("Composer pinned line three");
await receiverInput.press("Enter");
await receiverInput.type("Composer pinned line four");
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
await sendChannelMessage(pageOne, {
channelName,
content: incomingMessage,
});
await expectTimelineToContain(pageTwo, incomingMessage);
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toHaveCount(
0,
);
} finally {
await contextOne.close();
await contextTwo.close();
}
});
test("keeps scroll position when new messages arrive above the fold", async ({
browser,
}: {
browser: Browser;
}) => {
test.slow();
const channelName = `scroll-shared-${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
const prefix = `Scroll behavior ${Date.now()}`;
const incomingMessage = `${prefix} incoming`;
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await pageTwo.goto("/");
const minAwayDistance = 80;
await createAndJoinSharedStream(pageOne, pageTwo, channelName, {
prefix,
minDistance: 480,
});
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
const distanceBeforeDelivery = await scrollTimelineAwayFromBottom(
pageTwo,
minAwayDistance,
);
await sendChannelMessage(pageOne, {
channelName,
content: incomingMessage,
});
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toContainText(
"1 new message",
);
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeGreaterThanOrEqual(distanceBeforeDelivery - 8);
await pageTwo.getByTestId("message-scroll-to-latest").click();
await expectTimelineToContain(pageTwo, incomingMessage);
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
} finally {
await contextOne.close();
await contextTwo.close();
}
});