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>
2087 lines
75 KiB
TypeScript
2087 lines
75 KiB
TypeScript
import { expect, test, type Locator } from "@playwright/test";
|
|
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
|
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
|
|
import { openSettings } from "../helpers/settings";
|
|
|
|
async function waitForReadyComposerSnapshots(
|
|
page: import("@playwright/test").Page,
|
|
count = 1,
|
|
) {
|
|
await expect(page.locator("[data-composer-link-previews]")).toHaveAttribute(
|
|
"data-ready-snapshot-count",
|
|
String(count),
|
|
);
|
|
}
|
|
|
|
async function expectThreadReplyUnobscured(row: Locator) {
|
|
await expect
|
|
.poll(async () =>
|
|
row.evaluate((element) => {
|
|
const threadBody = element.closest(
|
|
'[data-testid="message-thread-body"]',
|
|
) as HTMLElement | null;
|
|
const threadPanel = element.closest(
|
|
'[data-testid="message-thread-panel"]',
|
|
) as HTMLElement | null;
|
|
const composer = threadPanel?.querySelector<HTMLElement>(
|
|
'[data-testid="message-input"]',
|
|
);
|
|
if (!threadBody || !composer) return false;
|
|
|
|
const rowRect = element.getBoundingClientRect();
|
|
const bodyRect = threadBody.getBoundingClientRect();
|
|
const composerRect = composer.getBoundingClientRect();
|
|
const visibleBottom = Math.min(bodyRect.bottom, composerRect.top);
|
|
return (
|
|
rowRect.top >= bodyRect.top - 1 && rowRect.bottom <= visibleBottom + 1
|
|
);
|
|
}),
|
|
)
|
|
.toBe(true);
|
|
}
|
|
|
|
async function measureThreadSummaryGeometry(summaryRow: Locator) {
|
|
return summaryRow.evaluate((summaryButton) => {
|
|
const summaryWrapper = summaryButton.parentElement;
|
|
const container = summaryWrapper?.parentElement;
|
|
const messageRow = container?.querySelector<HTMLElement>(
|
|
'[data-testid="message-row"]',
|
|
);
|
|
const messageMarkdown =
|
|
messageRow?.querySelector<HTMLElement>(".message-markdown");
|
|
const messageAuthor = messageRow?.querySelector<HTMLElement>(
|
|
'[data-testid="message-author"]',
|
|
);
|
|
const firstParticipant = summaryButton.querySelector<HTMLElement>(
|
|
'[data-testid="message-thread-summary-participant"]',
|
|
);
|
|
const summarySurface = summaryButton.querySelector<HTMLElement>(
|
|
'[data-testid="message-thread-summary-surface"]',
|
|
);
|
|
const firstAvatar = firstParticipant?.firstElementChild;
|
|
|
|
if (
|
|
!summaryWrapper ||
|
|
!container ||
|
|
!messageRow ||
|
|
!messageAuthor ||
|
|
!messageMarkdown ||
|
|
!summarySurface ||
|
|
!(firstAvatar instanceof HTMLElement)
|
|
) {
|
|
throw new Error("Expected measurable thread summary geometry.");
|
|
}
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
|
const messageRowRect = messageRow.getBoundingClientRect();
|
|
const messageAuthorRect = messageAuthor.getBoundingClientRect();
|
|
const messageMarkdownRect = messageMarkdown.getBoundingClientRect();
|
|
const summaryButtonRect = summaryButton.getBoundingClientRect();
|
|
const summaryButtonStyle = getComputedStyle(summaryButton);
|
|
const summaryButtonPaddingLeft = Number.parseFloat(
|
|
summaryButtonStyle.paddingLeft,
|
|
);
|
|
const summaryWrapperRect = summaryWrapper.getBoundingClientRect();
|
|
const firstAvatarRect = firstAvatar.getBoundingClientRect();
|
|
const summarySurfaceRect = summarySurface.getBoundingClientRect();
|
|
|
|
return {
|
|
authorLeft: messageAuthorRect.left,
|
|
avatarLeft: firstAvatarRect.left,
|
|
bodyLeft: messageMarkdownRect.left,
|
|
bottomPadding: containerRect.bottom - summaryWrapperRect.bottom,
|
|
messageRowLeft: messageRowRect.left,
|
|
summaryButtonContentLeft:
|
|
summaryButtonRect.left + summaryButtonPaddingLeft,
|
|
summaryButtonLeft: summaryButtonRect.left,
|
|
summaryButtonPaddingLeft,
|
|
summarySurfaceLeft: summarySurfaceRect.left,
|
|
topPadding: messageRowRect.top - containerRect.top,
|
|
};
|
|
});
|
|
}
|
|
|
|
test.beforeEach(async ({ page }, testInfo) => {
|
|
const baseMock = testInfo.title.includes("agent owner label")
|
|
? {
|
|
searchProfiles: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
displayName: "alice",
|
|
ownerPubkey: TEST_IDENTITIES.bob.pubkey,
|
|
isAgent: true,
|
|
},
|
|
{
|
|
pubkey: TEST_IDENTITIES.bob.pubkey,
|
|
displayName: "bob",
|
|
},
|
|
],
|
|
}
|
|
: testInfo.title.includes("cardless short tweet preview")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "jack (@jack) on X",
|
|
siteName: "X (formerly Twitter)",
|
|
description: "just setting up my twttr",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
},
|
|
}
|
|
: testInfo.title.includes("cardless tweet preview")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Buzz (@buzz) on X",
|
|
siteName: "X (formerly Twitter)",
|
|
description:
|
|
"This is a real tweet-style description long enough to wrap across several lines while preserving the message-like treatment. It keeps going with enough distinct words to exceed five rendered lines at the preview width, proving that the overflow-aware control appears only when the content is genuinely clipped rather than relying on a brittle character-count guess.",
|
|
imageDataUrl:
|
|
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='675'%3E%3Crect width='1200' height='675' fill='%231d9bf0'/%3E%3C/svg%3E",
|
|
imageDomain: "pbs.twimg.com",
|
|
},
|
|
}
|
|
: testInfo.title.includes("mixed link preview image outcomes")
|
|
? {
|
|
linkPreviewMetadataByHref: {
|
|
"https://github.com/block/buzz/pull/4001": {
|
|
title: "Loaded preview image",
|
|
siteName: "GitHub",
|
|
description: "The image request completed.",
|
|
imageDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
imageDomain: "opengraph.githubassets.com",
|
|
imageFetchState: "image",
|
|
imageRetryAfterMs: null,
|
|
},
|
|
"https://github.com/block/buzz/pull/4002": {
|
|
title: "Rate-limited preview image",
|
|
siteName: "GitHub",
|
|
description: "Metadata remains available during cooldown.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
imageFetchState: "transient_failure",
|
|
imageRetryAfterMs: 900_000,
|
|
},
|
|
},
|
|
}
|
|
: testInfo.title.includes("link preview browser image error")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Invalid decoded preview image",
|
|
siteName: "GitHub",
|
|
description: "The browser should replace this image.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
imageFetchState: "rejected",
|
|
imageRetryAfterMs: null,
|
|
},
|
|
}
|
|
: testInfo.title.includes("link preview image geometry")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title:
|
|
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
|
|
siteName: "GitHub",
|
|
description: "A polished, stable preview for shared links.",
|
|
imageDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
imageDomain: "opengraph.githubassets.com",
|
|
faviconDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
},
|
|
linkPreviewMetadataDelayMs: 800,
|
|
}
|
|
: testInfo.title.includes("link preview no-image layout") ||
|
|
testInfo.title.includes("composer no-image link embeds")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Buzz",
|
|
siteName: "GitHub",
|
|
description:
|
|
"Open-source collaboration for the Buzz app.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
faviconDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
},
|
|
linkPreviewMetadataDelayMs: 2_000,
|
|
}
|
|
: testInfo.title.includes(
|
|
"rich link preview preserves description newlines",
|
|
)
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Buzz pull request",
|
|
siteName: "GitHub",
|
|
description:
|
|
"First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
},
|
|
}
|
|
: testInfo.title.includes("link preview") ||
|
|
testInfo.title.includes("supported Compact")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Buzz pull request",
|
|
siteName: "GitHub",
|
|
description: "A sender-authored preview snapshot.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
},
|
|
linkPreviewMetadataDelayMs: testInfo.title.includes(
|
|
"loading card before cold resolver work",
|
|
)
|
|
? 10_000
|
|
: testInfo.title.includes("style defaults") ||
|
|
testInfo.title.includes("send does not wait") ||
|
|
testInfo.title.includes("attachment-sized")
|
|
? 1_500
|
|
: undefined,
|
|
linkPreviewMetadataStartBlockMs:
|
|
testInfo.title.includes(
|
|
"loading card before cold resolver work",
|
|
)
|
|
? 150
|
|
: undefined,
|
|
}
|
|
: undefined;
|
|
const mock = testInfo.title.includes("unresolvable preview")
|
|
? { linkPreviewMetadata: null, linkPreviewMetadataDelayMs: 150 }
|
|
: baseMock;
|
|
await installMockBridge(page, mock);
|
|
});
|
|
|
|
test("agent owner label identifies the agent and owner", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
const aliceMessage = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Hey team — checking in." });
|
|
const ownerTreatment = aliceMessage.getByTestId("message-agent-owner");
|
|
|
|
await expect(ownerTreatment.locator("svg")).toBeVisible();
|
|
await expect(
|
|
ownerTreatment.getByText("managed by", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(ownerTreatment.locator(".font-semibold")).toHaveText("bob");
|
|
await expect(ownerTreatment.getByRole("button")).toHaveAccessibleName("bob");
|
|
await expect(ownerTreatment.locator(".sr-only")).toHaveText(
|
|
"Agent managed by",
|
|
);
|
|
});
|
|
|
|
test("send a message and see it in timeline", async ({ page }) => {
|
|
const message = `Hello timeline ${Date.now()}`;
|
|
|
|
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 expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
await expect(page.getByTestId("message-row").last()).toContainText(
|
|
"npub1mock...",
|
|
);
|
|
});
|
|
|
|
test("long autolink wraps without widening the timeline", async ({ page }) => {
|
|
await page.setViewportSize({ width: 800, height: 600 });
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const longUrl = `https://blocked.teams.cloudflare.com/?${"dependencyconfusionnpm".repeat(18)}`;
|
|
const message = `Step "adapter" failed: npm error invalid json response body at <${longUrl}> reason: Unexpected token '<'`;
|
|
|
|
await page.getByTestId("message-input").fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline).toContainText('Step "adapter" failed');
|
|
await expect
|
|
.poll(() =>
|
|
timeline.evaluate((element) => element.scrollWidth - element.clientWidth),
|
|
)
|
|
.toBeLessThanOrEqual(1);
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await row.hover();
|
|
|
|
const actionBar = page.locator('[data-testid^="message-action-bar-"]').last();
|
|
await expect(actionBar).toHaveCSS("opacity", "1");
|
|
await expect
|
|
.poll(async () => {
|
|
const [barBox, timelineBox] = await Promise.all([
|
|
actionBar.boundingBox(),
|
|
timeline.boundingBox(),
|
|
]);
|
|
if (!barBox || !timelineBox) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
return barBox.x + barBox.width - (timelineBox.x + timelineBox.width);
|
|
})
|
|
.toBeLessThanOrEqual(0);
|
|
});
|
|
|
|
test("markdown tables overflow wide content and fill the message when narrow", async ({
|
|
page,
|
|
}) => {
|
|
await page.setViewportSize({ width: 900, height: 600 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
|
|
const longCell = "WIDE TABLE COLUMN VALUE ".repeat(8);
|
|
await page.evaluate(
|
|
({ wide, narrow }) => {
|
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content: wide,
|
|
createdAt,
|
|
});
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content: narrow,
|
|
createdAt: createdAt + 1,
|
|
});
|
|
},
|
|
{
|
|
wide: `| ${longCell} | ${longCell} | ${longCell} |\n| --- | --- | --- |\n| ${longCell} | ${longCell} | ${longCell} |`,
|
|
narrow: "| NARROW TABLE | VALUE |\n| --- | --- |\n| alpha | beta |",
|
|
},
|
|
);
|
|
|
|
const wideTable = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "WIDE TABLE COLUMN VALUE" })
|
|
.locator("[data-table-block]");
|
|
const narrowTable = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "NARROW TABLE" })
|
|
.locator("[data-table-block]");
|
|
await expect(wideTable).toBeVisible();
|
|
await expect(narrowTable).toBeVisible();
|
|
|
|
await expect
|
|
.poll(() =>
|
|
wideTable.evaluate(
|
|
(element) => element.scrollWidth - element.clientWidth,
|
|
),
|
|
)
|
|
.toBeGreaterThan(1);
|
|
await expect
|
|
.poll(() =>
|
|
narrowTable.evaluate((element) => {
|
|
const table = element.querySelector("table");
|
|
return table
|
|
? Math.abs(table.getBoundingClientRect().width - element.clientWidth)
|
|
: Number.POSITIVE_INFINITY;
|
|
}),
|
|
)
|
|
.toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test("link preview style defaults to compact and Rich unfurls descriptions", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?inline=1";
|
|
await page.setViewportSize({ width: 800, height: 900 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
const composerPreview = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(composerPreview).toHaveAttribute("data-image-state", "pending");
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/compact-composer-loading.png`,
|
|
});
|
|
}
|
|
await waitForReadyComposerSnapshots(page);
|
|
await expect(composerPreview).toHaveAttribute("data-image-state", "none");
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/compact-composer-ready.png`,
|
|
});
|
|
}
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
const compactPreview = row.locator(
|
|
'[data-link-preview="github-pull-request"]',
|
|
);
|
|
await expect(compactPreview).toHaveCSS("border-top-left-radius", "0px");
|
|
await expect(compactPreview).toHaveCSS("border-left-width", "3px");
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/recipient-compact.png`,
|
|
});
|
|
}
|
|
|
|
await openSettings(page, "appearance");
|
|
await expect(page.getByTestId("link-preview-style-trigger")).toHaveText(
|
|
"Compact",
|
|
);
|
|
await page.getByTestId("link-preview-style-trigger").click();
|
|
await page.getByTestId("link-preview-style-rich").click();
|
|
await expect(page.getByTestId("link-preview-style-trigger")).toHaveText(
|
|
"Rich",
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
localStorage.getItem("buzz.appearance.linkPreviewStyle"),
|
|
),
|
|
)
|
|
.toBe("rich");
|
|
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
const richPreview = row.locator(
|
|
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
|
|
);
|
|
await expect(richPreview).toBeVisible();
|
|
const richHostname = richPreview.locator("[data-link-preview-hostname]");
|
|
await expect(richHostname).toHaveText("github.com");
|
|
await expect(richHostname).toHaveAttribute("href", previewUrl);
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/recipient-rich.png`,
|
|
});
|
|
const richComposerUrl = `${previewUrl}&composer=rich`;
|
|
await page.getByTestId("message-input").fill(richComposerUrl);
|
|
const richComposerPreview = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(richComposerPreview).toHaveAttribute(
|
|
"data-image-state",
|
|
"pending",
|
|
);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/rich-composer-loading.png`,
|
|
});
|
|
await waitForReadyComposerSnapshots(page);
|
|
await expect(richComposerPreview).toHaveAttribute(
|
|
"data-image-state",
|
|
"none",
|
|
);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/rich-composer-ready.png`,
|
|
});
|
|
await page.getByTestId("message-input").fill("");
|
|
}
|
|
|
|
await openSettings(page, "appearance");
|
|
await page.getByTestId("link-preview-style-trigger").click();
|
|
await page.getByTestId("link-preview-style-compact").click();
|
|
});
|
|
|
|
for (const [pasteShape, wrapUrl] of [
|
|
["bare", (url: string) => url],
|
|
["angle-bracket", (url: string) => `<${url}>`],
|
|
] as const) {
|
|
test(`${pasteShape} link preview paste paints before cold resolver work`, async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = `https://github.com/block/buzz/pull/3246?paste=${pasteShape}`;
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.focus();
|
|
|
|
const pasteText = wrapUrl(previewUrl);
|
|
const firstPaint = await input.evaluate(async (element, pasteText) => {
|
|
const clipboardData = new DataTransfer();
|
|
clipboardData.setData("text/plain", pasteText);
|
|
const startedAt = performance.now();
|
|
element.dispatchEvent(
|
|
new ClipboardEvent("paste", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clipboardData,
|
|
}),
|
|
);
|
|
await new Promise<void>((resolve) =>
|
|
requestAnimationFrame(() => resolve()),
|
|
);
|
|
return {
|
|
elapsedMs: performance.now() - startedAt,
|
|
resolverStarted: (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some(
|
|
(entry) => entry.command === "fetch_link_preview_metadata",
|
|
),
|
|
text: element.textContent,
|
|
};
|
|
}, pasteText);
|
|
expect(firstPaint.text).toContain(previewUrl);
|
|
expect(firstPaint.resolverStarted).toBe(false);
|
|
expect(firstPaint.elapsedMs).toBeLessThan(100);
|
|
const composerPreview = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(input).toContainText(previewUrl, { timeout: 1_000 });
|
|
await expect(composerPreview).toHaveAttribute(
|
|
"data-state",
|
|
/^(processing|done)$/,
|
|
{ timeout: 1_000 },
|
|
);
|
|
await expect(page.getByTestId("send-message")).toBeEnabled();
|
|
});
|
|
}
|
|
|
|
test("display-text link preview produces and sends its preview", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?display=text";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await input.pressSequentially("review the pull request");
|
|
await page.keyboard.press("ControlOrMeta+a");
|
|
await page.keyboard.press("ControlOrMeta+k");
|
|
const dialog = page.getByRole("dialog", { name: "Add link" });
|
|
await dialog.getByLabel("URL").fill(previewUrl);
|
|
await dialog.getByRole("button", { name: "Save" }).click();
|
|
|
|
await expect(input.locator(`a[href="${previewUrl}"]`)).toHaveText(
|
|
"review the pull request",
|
|
);
|
|
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
|
|
await waitForReadyComposerSnapshots(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(
|
|
row.getByRole("link", { name: "review the pull request", exact: true }),
|
|
).toHaveAttribute("href", previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
|
const linkPreviewTags = await page.evaluate(() => {
|
|
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
|
.reverse()
|
|
.find((entry) => entry.command === "send_channel_message");
|
|
return (
|
|
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
|
)?.linkPreviewTags;
|
|
});
|
|
expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]);
|
|
});
|
|
|
|
test("rich link preview preserves description newlines after sending", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?newlines=1";
|
|
await page.addInitScript(() =>
|
|
localStorage.setItem("buzz.appearance.linkPreviewStyle", "rich"),
|
|
);
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
await waitForReadyComposerSnapshots(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const description = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator('[data-link-preview-inline] [data-slot="attachment-description"]');
|
|
await expect(description.locator("p")).toHaveCount(2);
|
|
await expect(description).toHaveText(
|
|
"First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.",
|
|
{ useInnerText: true },
|
|
);
|
|
});
|
|
|
|
test("completed link previews send when one URL has an unsnapshotable fragment", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrls = [
|
|
"https://twitter.com/tellaho",
|
|
"https://github.com/block/buzz/pull/3246",
|
|
"https://x.com/tellaho/status/1884289176381841506#",
|
|
];
|
|
const pastedText = previewUrls.join("\n");
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.focus();
|
|
await input.evaluate((element, text) => {
|
|
const clipboardData = new DataTransfer();
|
|
clipboardData.setData("text/plain", text);
|
|
element.dispatchEvent(
|
|
new ClipboardEvent("paste", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clipboardData,
|
|
}),
|
|
);
|
|
}, pastedText);
|
|
const composerPreviewCards = page.locator(
|
|
"[data-link-preview-composer-card]",
|
|
);
|
|
await expect(composerPreviewCards).toHaveCount(2);
|
|
await expect(
|
|
composerPreviewCards.locator(`a[href="${previewUrls[2]}"]`),
|
|
).toHaveCount(0);
|
|
await waitForReadyComposerSnapshots(page, 2);
|
|
|
|
const send = page.getByTestId("send-message");
|
|
await expect(send).toBeEnabled();
|
|
await send.click();
|
|
await expect(page.getByTestId("message-input")).toHaveText("");
|
|
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
|
await page.waitForTimeout(250);
|
|
await expect(page.getByTestId("message-input")).toHaveText("");
|
|
|
|
const calls = await page.evaluate(() =>
|
|
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
),
|
|
);
|
|
expect(calls).toHaveLength(1);
|
|
expect(
|
|
(
|
|
calls[0]?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
|
)?.linkPreviewTags?.map((tag) => tag[3]),
|
|
).toEqual(previewUrls.slice(0, 2));
|
|
});
|
|
|
|
test("unresolvable preview disappears after the terminal miss", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://x.com/tellaho/status";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
|
|
const composerPreviews = page.locator("[data-composer-link-previews]");
|
|
await expect(composerPreviews).toBeVisible();
|
|
await expect(
|
|
composerPreviews.locator("[data-link-preview-composer-card]"),
|
|
).toHaveAttribute("data-image-state", "pending");
|
|
await expect(composerPreviews).toHaveCount(0);
|
|
|
|
await page.getByTestId("send-message").click();
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
|
|
});
|
|
|
|
test("send does not wait for a pending link preview snapshot", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?send=pending";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
|
|
const composerPreviews = page.locator("[data-composer-link-previews]");
|
|
await expect(composerPreviews).toHaveAttribute(
|
|
"data-ready-snapshot-count",
|
|
"0",
|
|
);
|
|
await expect(
|
|
composerPreviews.locator('[data-link-preview="github-pull-request"]'),
|
|
).toHaveAttribute("data-image-state", "pending");
|
|
|
|
await page.getByTestId("send-message").click();
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
|
|
|
|
const linkPreviewTags = await page.evaluate(() => {
|
|
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
|
.reverse()
|
|
.find((entry) => entry.command === "send_channel_message");
|
|
return (
|
|
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
|
)?.linkPreviewTags;
|
|
});
|
|
expect(linkPreviewTags ?? []).toEqual([]);
|
|
});
|
|
|
|
test("hiding composer link previews suppresses the whole draft and emits the blanket marker", async ({
|
|
page,
|
|
}) => {
|
|
const firstUrl = "https://github.com/block/buzz/pull/3246?hide=all";
|
|
const secondUrl = "https://linear.app/acme/issue/ABC-123/hidden-too";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(firstUrl);
|
|
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
|
|
await waitForReadyComposerSnapshots(page);
|
|
|
|
await page.getByTestId("composer-hide-link-previews").click();
|
|
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
|
await page.getByTestId("message-input").fill(`${firstUrl} ${secondUrl}`);
|
|
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(firstUrl);
|
|
await expect(row).toContainText(secondUrl);
|
|
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
|
|
const linkPreviewTags = await page.evaluate(() => {
|
|
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
|
.reverse()
|
|
.find((entry) => entry.command === "send_channel_message");
|
|
return (
|
|
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
|
)?.linkPreviewTags;
|
|
});
|
|
expect(linkPreviewTags).toEqual([["link-preview", "none"]]);
|
|
|
|
await page.getByTestId("message-input").fill(firstUrl);
|
|
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
|
|
});
|
|
|
|
test("composer link preview embeds stay attachment-sized while loading and ready", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246";
|
|
|
|
for (const width of [800, 420]) {
|
|
await page.setViewportSize({ width: 800, height: 700 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.setViewportSize({ width, height: 700 });
|
|
await page
|
|
.getByTestId("message-input")
|
|
.fill(`${previewUrl}?viewport=${width}`);
|
|
const card = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(card).toBeVisible();
|
|
const initial = await card.evaluate((element) => ({
|
|
height: element.getBoundingClientRect().height,
|
|
width: element.getBoundingClientRect().width,
|
|
thumbnailHeight: element
|
|
.querySelector("[data-link-preview-thumbnail]")
|
|
?.getBoundingClientRect().height,
|
|
thumbnailWidth: element
|
|
.querySelector("[data-link-preview-thumbnail]")
|
|
?.getBoundingClientRect().width,
|
|
}));
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-loading.png`,
|
|
});
|
|
}
|
|
|
|
await expect
|
|
.poll(() =>
|
|
card.evaluate((element) => element.getAttribute("data-state")),
|
|
)
|
|
.toBe("done");
|
|
const ready = await card.evaluate((element) => ({
|
|
height: element.getBoundingClientRect().height,
|
|
width: element.getBoundingClientRect().width,
|
|
thumbnailHeight: element
|
|
.querySelector("[data-link-preview-thumbnail]")
|
|
?.getBoundingClientRect().height,
|
|
thumbnailWidth: element
|
|
.querySelector("[data-link-preview-thumbnail]")
|
|
?.getBoundingClientRect().width,
|
|
}));
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-ready.png`,
|
|
});
|
|
}
|
|
|
|
expect(initial.height).toBe(55);
|
|
expect(initial.width).toBe(320);
|
|
expect(initial.thumbnailHeight).toBe(55);
|
|
expect(initial.thumbnailWidth).toBe(55);
|
|
expect(ready).toEqual(initial);
|
|
}
|
|
});
|
|
|
|
test("composer no-image link embeds keep the attachment footprint", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?inline=none";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
const card = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(card).toHaveAttribute("data-image-state", "none");
|
|
await expect(card.locator("[data-link-preview-thumbnail]")).toBeVisible();
|
|
await expect(card.locator('[data-slot="attachment-title"]')).toContainText(
|
|
/github\.com|Buzz/,
|
|
);
|
|
await expect(card).toHaveCSS("height", "55px");
|
|
});
|
|
|
|
test("mixed link preview image outcomes keep Compact and Rich fallbacks stable", async ({
|
|
page,
|
|
}) => {
|
|
const loadedUrl = "https://github.com/block/buzz/pull/4001";
|
|
const rateLimitedUrl = "https://github.com/block/buzz/pull/4002";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page
|
|
.getByTestId("message-input")
|
|
.fill(`${loadedUrl}\n${rateLimitedUrl}`);
|
|
await waitForReadyComposerSnapshots(page, 2);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
const compactCards = row.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(compactCards).toHaveCount(2);
|
|
await expect(compactCards.nth(0)).toHaveAttribute(
|
|
"data-image-state",
|
|
"image",
|
|
);
|
|
await expect(
|
|
compactCards.nth(0).locator("[data-link-preview-thumbnail]"),
|
|
).toBeVisible();
|
|
await expect(compactCards.nth(1)).toHaveAttribute("data-image-state", "none");
|
|
await expect(
|
|
compactCards.nth(1).locator("[data-link-preview-image-fallback]"),
|
|
).toHaveCount(0);
|
|
|
|
await openSettings(page, "appearance");
|
|
await page.getByTestId("link-preview-style-trigger").click();
|
|
await page.getByTestId("link-preview-style-rich").click();
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
|
|
const richCards = row.locator(
|
|
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
|
|
);
|
|
await expect(richCards).toHaveCount(2);
|
|
await expect(
|
|
richCards.nth(1).locator("[data-link-preview-image-fallback]"),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("link preview browser image errors render a fallback", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page
|
|
.getByTestId("message-input")
|
|
.fill("https://github.com/block/buzz/pull/4003");
|
|
await waitForReadyComposerSnapshots(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
const compactCard = row.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(compactCard).toHaveAttribute("data-image-state", "none");
|
|
await expect(
|
|
compactCard.locator("[data-link-preview-image-fallback]"),
|
|
).toHaveCount(0);
|
|
|
|
await openSettings(page, "appearance");
|
|
await page.getByTestId("link-preview-style-trigger").click();
|
|
await page.getByTestId("link-preview-style-rich").click();
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
|
|
const richCard = row.locator(
|
|
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
|
|
);
|
|
await expect(
|
|
richCard.locator("[data-link-preview-image-fallback]"),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("supported Compact link previews keep the message link visible with square outer corners", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/sprout/pull/1334";
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
await waitForReadyComposerSnapshots(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(
|
|
row.getByRole("link", { exact: true, name: previewUrl }),
|
|
).toBeVisible();
|
|
const previewCard = row.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(previewCard).toBeVisible();
|
|
await expectCornerRadiusPx(previewCard, 0);
|
|
});
|
|
|
|
test("send multiple messages in sequence", async ({ page }) => {
|
|
const ts = Date.now();
|
|
const messages = [
|
|
`First message ${ts}`,
|
|
`Second message ${ts}`,
|
|
`Third message ${ts}`,
|
|
];
|
|
const input = page.getByTestId("message-input");
|
|
const sendButton = page.getByTestId("send-message");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
for (const message of messages) {
|
|
await input.fill(message);
|
|
await sendButton.click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
}
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
for (const message of messages) {
|
|
await expect(timeline).toContainText(message);
|
|
}
|
|
});
|
|
|
|
test("copy a rendered code block and paste it back as code", async ({
|
|
page,
|
|
}) => {
|
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
|
origin: "http://127.0.0.1:4173",
|
|
});
|
|
|
|
const code = "# not a heading\nconst answer = 42;\n indented();";
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await page.evaluate(
|
|
(text) => navigator.clipboard.writeText(text),
|
|
`\`\`\`ts\n${code}\n\`\`\``,
|
|
);
|
|
await input.click();
|
|
await page.keyboard.press("ControlOrMeta+V");
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const codeBlock = page.locator("[data-code-block]");
|
|
await expect(codeBlock).toHaveCount(1);
|
|
await expectCornerRadiusPx(codeBlock.locator("pre"), 16);
|
|
await expectSmoothCorners(codeBlock.locator("pre"));
|
|
|
|
const copyButton = page.getByLabel("Copy code block");
|
|
await expect(copyButton).toHaveCSS("opacity", "0");
|
|
await codeBlock.hover();
|
|
await expect(copyButton).toHaveCSS("opacity", "1");
|
|
await copyButton.click();
|
|
await expect
|
|
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
|
.toBe(code);
|
|
|
|
await input.click();
|
|
await page.keyboard.press("ControlOrMeta+V");
|
|
await input.press("Enter");
|
|
|
|
await expect(codeBlock).toHaveCount(2);
|
|
});
|
|
|
|
test("pasting a long copied code block scrolls composer to cursor", async ({
|
|
page,
|
|
}) => {
|
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
|
origin: "http://127.0.0.1:4173",
|
|
});
|
|
|
|
const longCode = Array.from(
|
|
{ length: 48 },
|
|
(_, index) => `const line${index} = ${index};`,
|
|
).join("\n");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await page.evaluate(
|
|
(text) => navigator.clipboard.writeText(text),
|
|
`\`\`\`ts\n${longCode}\n\`\`\``,
|
|
);
|
|
await input.click();
|
|
await page.keyboard.press("ControlOrMeta+V");
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const copiedCodeBlock = page.locator("[data-code-block]");
|
|
await expect(copiedCodeBlock).toHaveCount(1);
|
|
await copiedCodeBlock.hover();
|
|
await page.getByLabel("Copy code block").click();
|
|
|
|
await input.fill("typed before paste");
|
|
await page.keyboard.press("ControlOrMeta+V");
|
|
|
|
const scrollContainer = page.getByTestId("message-input-scroll");
|
|
await expect
|
|
.poll(() =>
|
|
scrollContainer.evaluate(
|
|
(element) =>
|
|
element.scrollHeight - element.clientHeight - element.scrollTop,
|
|
),
|
|
)
|
|
.toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test("code block shows language label when language is specified", async ({
|
|
page,
|
|
}) => {
|
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
|
origin: "http://127.0.0.1:4173",
|
|
});
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await page.evaluate(
|
|
(text) => navigator.clipboard.writeText(text),
|
|
"```typescript\nconst x = 1;\n```",
|
|
);
|
|
|
|
await input.click();
|
|
await page.keyboard.press("ControlOrMeta+V");
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const codeBlock = page.locator("[data-code-block]");
|
|
await expect(codeBlock).toBeVisible();
|
|
await expect(codeBlock.getByText("typescript")).toBeVisible();
|
|
});
|
|
|
|
test("typing triple backticks and Enter creates a code block in composer", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await page.keyboard.type("```");
|
|
await page.keyboard.press("Enter");
|
|
|
|
// A <pre> code block should appear inside the ProseMirror editor
|
|
const editorPre = input.locator("pre");
|
|
await expect(editorPre).toBeVisible();
|
|
|
|
// The literal backticks should be consumed (not visible as text)
|
|
await expect(input).not.toContainText("```");
|
|
});
|
|
|
|
test("message input clears after send", async ({ page }) => {
|
|
const message = `Clear after send ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await input.fill(message);
|
|
await expect(input).toHaveText(message);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
await expect(input).toHaveText("");
|
|
});
|
|
|
|
test("emoji picker inserts emoji into the draft and keeps focus in the composer", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Ship");
|
|
|
|
await page.getByTestId("composer-emoji-button").click();
|
|
|
|
// emoji-mart renders inside a Shadow DOM web component — use the search
|
|
// input to find the rocket emoji, then click it.
|
|
const pickerEl = page.locator("em-emoji-picker");
|
|
const searchInput = pickerEl.locator("input[type='search']");
|
|
await searchInput.fill("rocket");
|
|
await pickerEl.locator("button[aria-label='🚀']").first().click();
|
|
|
|
await expect(input).toHaveText("Ship🚀");
|
|
await expect(input).toBeFocused();
|
|
|
|
await input.pressSequentially(" now");
|
|
await expect(input).toHaveText("Ship🚀 now");
|
|
});
|
|
|
|
test("empty message cannot be sent", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const sendButton = page.getByTestId("send-message");
|
|
await expect(sendButton).toBeDisabled();
|
|
});
|
|
|
|
test("send message with Enter key", async ({ page }) => {
|
|
const message = `Enter key send ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await input.fill(message);
|
|
await input.press("Enter");
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
});
|
|
|
|
test("messages persist across channel switches", async ({ page }) => {
|
|
const message = `Persist across switch ${Date.now()}`;
|
|
|
|
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 expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
});
|
|
|
|
test("draft is preserved when switching channels", async ({ page }) => {
|
|
const draft = `Unsent draft ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Type a draft but do not send it
|
|
await input.fill(draft);
|
|
await expect(input).toHaveText(draft);
|
|
|
|
// Switch to another channel — composer should be empty
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await expect(input).toHaveText("");
|
|
|
|
// Switch back — the draft should still be there
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(input).toHaveText(draft);
|
|
});
|
|
|
|
test("sending a message clears the draft", async ({ page }) => {
|
|
const message = `Sent message ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Type and send a message
|
|
await input.fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
|
|
// Switch away and back — composer should be empty, not restored from draft
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(input).toHaveText("");
|
|
});
|
|
|
|
test("different channels have independent messages", async ({ page }) => {
|
|
const ts = Date.now();
|
|
const generalMessage = `General only ${ts}`;
|
|
const randomMessage = `Random only ${ts}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await page.getByTestId("message-input").fill(generalMessage);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
generalMessage,
|
|
);
|
|
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
|
generalMessage,
|
|
);
|
|
|
|
await page.getByTestId("message-input").fill(randomMessage);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
randomMessage,
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
generalMessage,
|
|
);
|
|
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
|
randomMessage,
|
|
);
|
|
});
|
|
|
|
test("day divider appears in timeline", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
"Welcome to #general",
|
|
);
|
|
await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible();
|
|
});
|
|
|
|
test("send message to DM channel p-tags the recipient", async ({ page }) => {
|
|
const message = `DM message ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-alice-tyler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
|
|
|
await page.getByTestId("message-input").fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate((content) => {
|
|
const events = (
|
|
window as Window & {
|
|
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
|
|
content: string;
|
|
tags: string[][];
|
|
}>;
|
|
}
|
|
).__BUZZ_E2E_SIGNED_EVENTS__;
|
|
return events?.find((event) => event.content === content)?.tags ?? [];
|
|
}, message),
|
|
)
|
|
.toContainEqual(["p", TEST_IDENTITIES.alice.pubkey]);
|
|
});
|
|
|
|
test("shows your avatar on your own message when profile avatar is set", async ({
|
|
page,
|
|
}) => {
|
|
const message = `Avatar message ${Date.now()}`;
|
|
const avatarUrl =
|
|
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E';
|
|
|
|
await page.goto("/");
|
|
await openSettings(page, "profile");
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await page.getByTestId("profile-avatar-url").fill(avatarUrl);
|
|
await page.getByTestId("profile-avatar-done").click();
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
|
|
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();
|
|
|
|
const lastMessage = page.getByTestId("message-row").last();
|
|
await expect(lastMessage).toContainText(message);
|
|
await expect(lastMessage.getByTestId("message-avatar-image")).toHaveAttribute(
|
|
"src",
|
|
avatarUrl,
|
|
);
|
|
});
|
|
|
|
test("opens a single-level thread panel with inline expansion", async ({
|
|
page,
|
|
}) => {
|
|
const timestamp = Date.now();
|
|
const firstReply = `First threaded reply ${timestamp}`;
|
|
const siblingReply = `Sibling threaded reply ${timestamp}`;
|
|
const nestedReply = `Nested threaded reply ${timestamp}`;
|
|
const nestedReplyFromBob = `Nested reply from Bob ${timestamp}`;
|
|
const fillerReplies = Array.from(
|
|
{ length: 14 },
|
|
(_, index) => `Thread filler reply ${index} ${timestamp}`,
|
|
);
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
"Welcome to #general",
|
|
);
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const timelineRows = timeline.getByTestId("message-row");
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadBody = threadPanel.getByTestId("message-thread-body");
|
|
const threadComposer = threadPanel.locator('[data-testid="message-input"]');
|
|
const threadSendButton = threadPanel.getByTestId("send-message");
|
|
const threadReplies = threadPanel.getByTestId("message-thread-replies");
|
|
const rootMessage = timelineRows.first();
|
|
const rootMessageId = await rootMessage.getAttribute("data-message-id");
|
|
if (!rootMessageId) {
|
|
throw new Error("Expected root message row to have a data-message-id.");
|
|
}
|
|
const rootSummaryRow = timeline.locator(
|
|
`[data-testid="message-thread-summary"][data-thread-head-id="${rootMessageId}"]`,
|
|
);
|
|
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
|
"Welcome to #general",
|
|
);
|
|
|
|
await threadComposer.fill(firstReply);
|
|
await threadSendButton.click();
|
|
await expect(threadReplies).toContainText(firstReply);
|
|
|
|
await threadComposer.fill(siblingReply);
|
|
await threadSendButton.click();
|
|
await expect(threadReplies).toContainText(siblingReply);
|
|
|
|
for (const fillerReply of fillerReplies) {
|
|
await threadComposer.fill(fillerReply);
|
|
await threadSendButton.click();
|
|
await expect(threadReplies).toContainText(fillerReply);
|
|
}
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return threadBody.evaluate((element) => {
|
|
const body = element as HTMLDivElement;
|
|
return body.scrollHeight - body.clientHeight;
|
|
});
|
|
})
|
|
// Compact continuation rows intentionally reduce the available overflow;
|
|
// this test only needs enough space to prove the thread body scrolls.
|
|
.toBeGreaterThan(0);
|
|
|
|
await expect(
|
|
timeline.getByTestId("message-row").filter({ hasText: firstReply }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
timeline.getByTestId("message-row").filter({ hasText: siblingReply }),
|
|
).toHaveCount(0);
|
|
|
|
await expect(rootSummaryRow).toContainText("16 replies");
|
|
await expect(
|
|
rootSummaryRow.getByTestId("message-thread-summary-participant"),
|
|
).toHaveCount(1);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-participant")
|
|
.first()
|
|
.evaluate((wrapper) => {
|
|
const avatar = wrapper.firstElementChild;
|
|
if (!(avatar instanceof HTMLElement)) return "missing";
|
|
const rect = avatar.getBoundingClientRect();
|
|
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
|
}),
|
|
)
|
|
.toBe("24x24");
|
|
const summaryGeometry = await measureThreadSummaryGeometry(rootSummaryRow);
|
|
expect(
|
|
Math.abs(summaryGeometry.authorLeft - summaryGeometry.bodyLeft),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(
|
|
Math.abs(summaryGeometry.avatarLeft - summaryGeometry.bodyLeft),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(
|
|
Math.abs(
|
|
summaryGeometry.summaryButtonContentLeft - summaryGeometry.bodyLeft,
|
|
),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(
|
|
Math.abs(
|
|
summaryGeometry.summaryButtonLeft - summaryGeometry.messageRowLeft,
|
|
),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(summaryGeometry.summaryButtonLeft).toBeLessThan(
|
|
summaryGeometry.bodyLeft,
|
|
);
|
|
expect(
|
|
Math.abs(
|
|
summaryGeometry.bodyLeft -
|
|
summaryGeometry.summaryButtonLeft -
|
|
summaryGeometry.summaryButtonPaddingLeft,
|
|
),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(summaryGeometry.summarySurfaceLeft).toBeLessThan(
|
|
summaryGeometry.avatarLeft,
|
|
);
|
|
expect(
|
|
Math.abs(
|
|
summaryGeometry.avatarLeft - summaryGeometry.summarySurfaceLeft - 4,
|
|
),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(
|
|
Math.abs(summaryGeometry.topPadding - summaryGeometry.bottomPadding),
|
|
).toBeLessThanOrEqual(1);
|
|
|
|
await page.mouse.move(0, 0);
|
|
const rootSummaryWidthBeforeHover = await rootSummaryRow.evaluate((row) =>
|
|
Math.round(row.getBoundingClientRect().width),
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-last-reply")
|
|
.evaluate((label) =>
|
|
Number.parseFloat(getComputedStyle(label).opacity),
|
|
),
|
|
)
|
|
.toBeGreaterThan(0.8);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-hover-action")
|
|
.evaluate((label) =>
|
|
Number.parseFloat(getComputedStyle(label).opacity),
|
|
),
|
|
)
|
|
.toBeLessThan(0.1);
|
|
await rootSummaryRow.hover();
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-last-reply")
|
|
.evaluate((label) =>
|
|
Number.parseFloat(getComputedStyle(label).opacity),
|
|
),
|
|
)
|
|
.toBeLessThan(0.1);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-hover-action")
|
|
.evaluate((label) =>
|
|
Number.parseFloat(getComputedStyle(label).opacity),
|
|
),
|
|
)
|
|
.toBeGreaterThan(0.8);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow.evaluate((row) =>
|
|
Math.round(row.getBoundingClientRect().width),
|
|
),
|
|
)
|
|
.toBe(rootSummaryWidthBeforeHover);
|
|
|
|
await threadPanel.getByTestId("auxiliary-panel-close").click();
|
|
await expect(threadPanel).toBeHidden();
|
|
|
|
await rootSummaryRow.click();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
|
"Welcome to #general",
|
|
);
|
|
|
|
const firstReplyRow = threadReplies
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: firstReply })
|
|
.first();
|
|
await firstReplyRow.hover();
|
|
await firstReplyRow.getByRole("button", { name: "Reply" }).click();
|
|
|
|
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
|
"Welcome to #general",
|
|
);
|
|
await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0);
|
|
|
|
await threadComposer.fill(nestedReply);
|
|
await threadSendButton.click();
|
|
|
|
const nestedReplyRow = threadReplies
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: nestedReply })
|
|
.first();
|
|
await expect(nestedReplyRow).toBeVisible();
|
|
await expect(
|
|
timeline.getByTestId("message-row").filter({ hasText: nestedReply }),
|
|
).toHaveCount(0);
|
|
|
|
await expect(
|
|
threadReplies.getByTestId("message-row").filter({ hasText: siblingReply }),
|
|
).toHaveCount(1);
|
|
await expectThreadReplyUnobscured(nestedReplyRow);
|
|
|
|
const firstReplyId = await firstReplyRow.getAttribute("data-message-id");
|
|
if (!firstReplyId) {
|
|
throw new Error("Expected first reply row to have a data-message-id.");
|
|
}
|
|
|
|
await page.evaluate(
|
|
({ content, parentEventId, pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content,
|
|
parentEventId,
|
|
pubkey,
|
|
});
|
|
},
|
|
{
|
|
content: nestedReplyFromBob,
|
|
parentEventId: firstReplyId,
|
|
pubkey: TEST_IDENTITIES.bob.pubkey,
|
|
},
|
|
);
|
|
const nestedReplyFromBobRow = threadReplies
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: nestedReplyFromBob })
|
|
.first();
|
|
await expect(nestedReplyFromBobRow).toBeVisible();
|
|
|
|
const firstReplySummaryRow = threadReplies.locator(
|
|
`[data-testid="message-thread-summary"][data-thread-head-id="${firstReplyId}"]`,
|
|
);
|
|
await expect(firstReplySummaryRow).toHaveCount(0);
|
|
const firstReplyBranchGuide = threadReplies.locator(
|
|
`[data-testid="thread-collapse-guide"][data-thread-head-id="${firstReplyId}"]`,
|
|
);
|
|
await expect(firstReplyBranchGuide).not.toHaveCount(0);
|
|
|
|
await expect(rootSummaryRow).toContainText("18 replies");
|
|
await expect(
|
|
rootSummaryRow.getByTestId("message-thread-summary-participant"),
|
|
).toHaveCount(2);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-participant")
|
|
.evaluateAll((participants) =>
|
|
participants
|
|
.map((participant) => getComputedStyle(participant).zIndex)
|
|
.join(","),
|
|
),
|
|
)
|
|
.toBe("1,2");
|
|
|
|
await expectThreadReplyUnobscured(nestedReplyRow);
|
|
|
|
await firstReplyBranchGuide.first().click();
|
|
await expect(firstReplySummaryRow).toHaveCount(1);
|
|
await expect(firstReplySummaryRow).toContainText("2 replies");
|
|
await expect(
|
|
threadReplies.getByTestId("message-row").filter({ hasText: nestedReply }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
threadReplies
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: nestedReplyFromBob }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("thread panel width uses session storage and reset handle", async ({
|
|
page,
|
|
}) => {
|
|
const customWidthPx = 520;
|
|
const defaultWidthPx = 380;
|
|
|
|
await page.addInitScript((width) => {
|
|
window.sessionStorage.setItem(
|
|
"buzz.desktop.thread-panel-width",
|
|
String(width),
|
|
);
|
|
}, customWidthPx);
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const rootMessage = timeline.getByTestId("message-row").first();
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const resizeHandle = threadPanel.getByTestId(
|
|
"right-auxiliary-pane-resize-handle",
|
|
);
|
|
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return threadPanel.evaluate((panel) => {
|
|
const element = panel as HTMLElement;
|
|
return Math.round(element.getBoundingClientRect().width);
|
|
});
|
|
})
|
|
.toBe(customWidthPx);
|
|
|
|
await resizeHandle.dblclick();
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return threadPanel.evaluate((panel) => {
|
|
const element = panel as HTMLElement;
|
|
return Math.round(element.getBoundingClientRect().width);
|
|
});
|
|
})
|
|
.toBe(defaultWidthPx);
|
|
|
|
await threadPanel.getByTestId("auxiliary-panel-close").click();
|
|
await expect(threadPanel).toBeHidden();
|
|
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return threadPanel.evaluate((panel) => {
|
|
const element = panel as HTMLElement;
|
|
return Math.round(element.getBoundingClientRect().width);
|
|
});
|
|
})
|
|
.toBe(defaultWidthPx);
|
|
});
|
|
|
|
test("narrow thread view collapses channel header actions into a menu", async ({
|
|
page,
|
|
}) => {
|
|
await page.setViewportSize({ width: 980, height: 720 });
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-actions-menu-trigger")).toHaveCount(0);
|
|
|
|
const rootMessage = page.locator('[data-message-id="mock-general-alice"]');
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
|
|
await rootMessage.hover();
|
|
await page.getByTestId("reply-message-mock-general-alice").click();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0);
|
|
|
|
const menuTrigger = page.getByTestId("channel-actions-menu-trigger");
|
|
await expect(menuTrigger).toBeVisible();
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-members-trigger")).toBeHidden();
|
|
await expect(page.getByTestId("channel-management-trigger")).toBeHidden();
|
|
|
|
const menuBox = await menuTrigger.boundingBox();
|
|
const threadPanelBox = await threadPanel.boundingBox();
|
|
if (!menuBox || !threadPanelBox) {
|
|
throw new Error("Expected header action menu and thread panel bounds");
|
|
}
|
|
const menuGap = threadPanelBox.x - (menuBox.x + menuBox.width);
|
|
const headerPaddingInlineEnd = await page
|
|
.getByTestId("chat-header")
|
|
.evaluate((header) =>
|
|
Number.parseFloat(window.getComputedStyle(header).paddingRight),
|
|
);
|
|
expect(menuGap).toBeGreaterThanOrEqual(0);
|
|
expect(menuGap).toBeLessThanOrEqual(headerPaddingInlineEnd + menuBox.width);
|
|
|
|
await menuTrigger.click();
|
|
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-members-trigger")).toBeVisible();
|
|
await expect(page.getByTestId("channel-start-huddle-trigger")).toBeVisible();
|
|
await expect(page.getByTestId("channel-management-trigger")).toBeVisible();
|
|
});
|
|
|
|
test("single-panel thread view hides channel actions", async ({ page }) => {
|
|
await page.setViewportSize({ width: 860, height: 720 });
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
|
|
const rootMessage = page.locator('[data-message-id="mock-general-alice"]');
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
|
|
await rootMessage.hover();
|
|
await page.getByTestId("reply-message-mock-general-alice").click();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-back")).toBeVisible();
|
|
await expect(page.getByTestId("channel-actions-menu-trigger")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
|
|
await threadPanel.getByTestId("message-thread-back").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
});
|
|
|
|
test("composer is focused after selecting a channel", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Without clicking the input, typing should land in the composer.
|
|
const input = page.getByTestId("message-input");
|
|
await expect(input).toBeFocused();
|
|
|
|
await page.keyboard.type("autofocus-on-channel-select");
|
|
await expect(input).toHaveText("autofocus-on-channel-select");
|
|
});
|
|
|
|
test("composer is focused after switching to a different channel", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await expect(input).toBeFocused();
|
|
});
|
|
|
|
test("thread composer is focused after clicking the reply icon", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Seed a message to reply to.
|
|
const seed = `Thread autofocus seed ${Date.now()}`;
|
|
const mainInput = page.getByTestId("message-input");
|
|
await mainInput.fill(seed);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(seed);
|
|
|
|
const rootMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await expect(threadInput).toBeFocused();
|
|
|
|
await page.keyboard.type("typed-into-thread");
|
|
await expect(threadInput).toHaveText("typed-into-thread");
|
|
});
|
|
|
|
test("thread refetch preserves a live reply and reaction received in flight", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { threadRepliesDelayMs: 800 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const rootMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.first();
|
|
const rootId = await rootMessage.getAttribute("data-message-id");
|
|
if (!rootId) throw new Error("Expected a thread root id.");
|
|
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
const reply = `Live reply during thread fetch ${Date.now()}`;
|
|
const replyId = await page.evaluate(
|
|
async ({ channelId, content, parentEventId }) => {
|
|
const bridgeWindow = window as Window & {
|
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
|
command: string,
|
|
payload?: Record<string, unknown>,
|
|
) => Promise<unknown>;
|
|
};
|
|
const invoke = bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock Tauri invoke bridge is unavailable.");
|
|
const sent = (await invoke("send_channel_message", {
|
|
channelId,
|
|
content,
|
|
parentEventId,
|
|
})) as { event_id: string };
|
|
await invoke("add_reaction", { eventId: sent.event_id, emoji: "👍" });
|
|
return sent.event_id;
|
|
},
|
|
{
|
|
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
content: reply,
|
|
parentEventId: rootId,
|
|
},
|
|
);
|
|
|
|
const replyRow = threadPanel.locator(`[data-message-id="${replyId}"]`);
|
|
await expect(replyRow).toContainText(reply);
|
|
await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible();
|
|
|
|
// The delayed get_thread_replies response was snapshotted before the live
|
|
// events. Wait past query completion: neither cache addition may disappear.
|
|
await page.waitForTimeout(1_200);
|
|
await expect(replyRow).toContainText(reply);
|
|
await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible();
|
|
});
|
|
|
|
test("thread reply appears after relay closes and restores its live subscription", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { closeChannelLiveSubscriptionOnce: true });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const seed = `Thread CLOSED seed ${Date.now()}`;
|
|
await page.getByTestId("message-input").fill(seed);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(seed);
|
|
|
|
const rootMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const reply = `Thread reply after CLOSED ${Date.now()}`;
|
|
await threadPanel.getByTestId("message-input").fill(reply);
|
|
await page.waitForTimeout(1_100);
|
|
await threadPanel.getByTestId("send-message").click();
|
|
|
|
await expect(threadPanel).toContainText(reply);
|
|
});
|
|
|
|
test("thread composer keeps focus after sending a thread reply", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Seed a root message we can open a thread on. At this point only one
|
|
// composer is mounted, so plain getByTestId is unambiguous.
|
|
const seed = `Thread focus-after-send seed ${Date.now()}`;
|
|
await page.getByTestId("message-input").fill(seed);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(seed);
|
|
|
|
const rootMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await expect(threadInput).toBeFocused();
|
|
|
|
// Send a thread reply. After the send, `isSending` flips and back to false
|
|
// in both the main and thread composers; the thread input must keep focus.
|
|
const reply = `Thread reply ${Date.now()}`;
|
|
await page.keyboard.type(reply);
|
|
await expect(threadInput).toHaveText(reply);
|
|
await page.keyboard.press("Enter");
|
|
|
|
// Wait for the send to settle.
|
|
await expect(threadPanel).toContainText(reply);
|
|
|
|
// The thread input should still be focused — not the main composer.
|
|
// Both composers expose the same `message-input` data-testid, so we
|
|
// verify directly that `document.activeElement` lives inside the thread
|
|
// panel rather than the main pane.
|
|
const focusInThreadPanel = await page.evaluate(() => {
|
|
const panel = document.querySelector<HTMLElement>(
|
|
'[data-testid="message-thread-panel"]',
|
|
);
|
|
const active = document.activeElement as HTMLElement | null;
|
|
return Boolean(panel && active && panel.contains(active));
|
|
});
|
|
expect(focusInThreadPanel).toBe(true);
|
|
|
|
await expect(threadInput).toBeFocused();
|
|
});
|
|
|
|
test("ArrowUp in an empty composer edits your last message right after sending", async ({
|
|
page,
|
|
}) => {
|
|
// Regression: after a send, the composer keeps DOM focus and ProseMirror
|
|
// would consume ArrowUp before it reached the edit-last-message handler,
|
|
// so ↑ did nothing until you clicked out and back. The handler now lives
|
|
// in the editor keymap, so ↑ must work with no intermediate click.
|
|
const message = `Edit-last via arrow up ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await input.fill(message);
|
|
await input.press("Enter");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
|
|
// Composer stays focused after send — no click, just press ↑.
|
|
await expect(input).toBeFocused();
|
|
await page.keyboard.press("ArrowUp");
|
|
|
|
// Edit mode is entered for the just-sent message.
|
|
const editBanner = page.getByTestId("edit-target");
|
|
await expect(editBanner).toBeVisible();
|
|
await expect(editBanner).toContainText("Editing message");
|
|
await expect(editBanner).not.toContainText(message);
|
|
await expect(input).toHaveText(message);
|
|
});
|
|
|
|
test("ArrowUp does not edit when the composer has draft text", async ({
|
|
page,
|
|
}) => {
|
|
// Guard: ↑ must only hijack to edit when the composer is empty, so it
|
|
// never steals the arrow key from someone navigating drafted text.
|
|
const sent = `Sent before draft ${Date.now()}`;
|
|
const draft = `Half-typed draft ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await input.fill(sent);
|
|
await input.press("Enter");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(sent);
|
|
|
|
await input.fill(draft);
|
|
await expect(input).toHaveText(draft);
|
|
await page.keyboard.press("ArrowUp");
|
|
|
|
// No edit mode; the draft is untouched.
|
|
await expect(page.getByTestId("edit-target")).toHaveCount(0);
|
|
await expect(input).toHaveText(draft);
|
|
});
|
|
|
|
test("ArrowUp edits your last thread reply right after sending it", async ({
|
|
page,
|
|
}) => {
|
|
// Same fix must hold in the thread composer (shares MessageComposer).
|
|
const seed = `Thread arrow-up seed ${Date.now()}`;
|
|
const reply = `Thread reply to edit ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill(seed);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(seed);
|
|
|
|
const rootMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await expect(threadInput).toBeFocused();
|
|
|
|
await page.keyboard.type(reply);
|
|
await page.keyboard.press("Enter");
|
|
await expect(threadPanel).toContainText(reply);
|
|
|
|
// No click — press ↑ in the still-focused thread composer.
|
|
await page.keyboard.press("ArrowUp");
|
|
|
|
const editBanner = threadPanel.getByTestId("edit-target");
|
|
await expect(editBanner).toBeVisible();
|
|
await expect(editBanner).toContainText("Editing message");
|
|
await expect(editBanner).not.toContainText(reply);
|
|
await expect(threadInput).toHaveText(reply);
|
|
});
|
|
|
|
test("action bar stays within the timeline when the thread panel is open", async ({
|
|
page,
|
|
}) => {
|
|
// Narrow viewport + open thread panel => the timeline shrinks to a column.
|
|
// A long unbreakable token must not widen message rows past that column,
|
|
// or the right-anchored action bar is pushed offscreen (regression: #1081
|
|
// fixed the wrap but rows still expanded to content min-width).
|
|
await page.setViewportSize({ width: 1024, height: 800 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const input = page.getByTestId("message-input").first();
|
|
const send = page.getByTestId("send-message").first();
|
|
|
|
const longUrl = `https://example.com/${"a".repeat(180)}/path`;
|
|
await input.fill(longUrl);
|
|
await send.click();
|
|
await expect(timeline).toContainText("example.com");
|
|
|
|
const rootMessage = timeline.getByTestId("message-row").first();
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
|
|
|
const wideRow = timeline.getByTestId("message-row").last();
|
|
await wideRow.scrollIntoViewIfNeeded();
|
|
await wideRow.hover();
|
|
const bar = wideRow.locator('[data-testid^="message-action-bar-"]');
|
|
await expect(bar).toBeVisible();
|
|
|
|
const timelineBox = await timeline.boundingBox();
|
|
const rowBox = await wideRow.boundingBox();
|
|
const barBox = await bar.boundingBox();
|
|
if (!timelineBox || !rowBox || !barBox) {
|
|
throw new Error("Expected timeline, row, and action bar to have geometry.");
|
|
}
|
|
|
|
expect(rowBox.x + rowBox.width).toBeLessThanOrEqual(
|
|
timelineBox.x + timelineBox.width + 1,
|
|
);
|
|
expect(barBox.x + barBox.width).toBeLessThanOrEqual(
|
|
timelineBox.x + timelineBox.width + 1,
|
|
);
|
|
});
|