Files
buzz/desktop/tests/e2e/image-attachment-gallery.spec.ts
klopez4212 0ee2ad7fbf Fix rounded macOS icon on older Macs (#8018)
Older macOS versions display Buzz’s opaque icon as an oversized square.
Bake rounded corners and transparent padding into the macOS `.icns`,
preserving the original bee artwork and leaving other platforms
unchanged.

Generation uses Hermit-pinned uv, Python 3.14.3, and Pillow 12.2.0.
`scripts/macos-icon.sh --check` regenerates temporarily and compares all
decoded representations; macOS CI runs it before packaging. Pixel
reproducibility is verified independently of Apple’s ICNS container
encoding.

Validated native AppKit preview, all 10 representations, fresh-cache
generation, one-pixel/missing-representation drift rejection, workflow
lint, and pre-push checks. Full local `just ci` is running. Packaged
Dock checks on older/current macOS remain outstanding.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-01 17:00:57 +01:00

1203 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, type Page, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
const IMAGE_SHAS = ["a".repeat(64), "b".repeat(64), "c".repeat(64)];
const SPOILER_VISIBLE_SHA = "d".repeat(64);
const SPOILER_HIDDEN_SHA = "e".repeat(64);
const SPOILER_VISIBLE_URL = `http://localhost:3000/media/${SPOILER_VISIBLE_SHA}.png`;
const SPOILER_HIDDEN_URL = `http://localhost:3000/media/${SPOILER_HIDDEN_SHA}.png`;
const NO_DIM_WIDE_URL = "https://example.com/e2e/gallery-wide.png";
const NO_DIM_PORTRAIT_URL = "https://example.com/e2e/gallery-portrait.png";
const NO_DIM_SECOND_URL = "https://example.com/e2e/gallery-second.png";
const PROGRESSIVE_URL = "https://example.com/e2e/progressive-full.png";
const PROGRESSIVE_THUMB_URL = "https://example.com/e2e/progressive-thumb.jpg";
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
.poll(async () => {
return page.evaluate((name) => {
return (
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false
);
}, channelName);
})
.toBe(true);
}
async function emitMockMessage(
page: Page,
content: string,
parentEventId?: string,
createdAt?: number,
extraTags?: string[][],
): Promise<string> {
const eventId = await page.evaluate(
({ body, createdAt: timestamp, parent, tags }) => {
const event = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
createdAt?: number;
parentEventId?: string;
extraTags?: string[][];
}) => { id?: string } | undefined;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: body,
createdAt: timestamp,
parentEventId: parent,
extraTags: tags,
});
return event?.id ?? null;
},
{ body: content, createdAt, parent: parentEventId, tags: extraTags },
);
if (!eventId) throw new Error("Expected mock message event id");
return eventId;
}
function imageImetaTag({
dim,
filename,
sha,
thumb,
url,
}: {
dim: string;
filename: string;
sha: string;
thumb?: string;
url: string;
}) {
return [
"imeta",
`url ${url}`,
"m image/png",
`x ${sha}`,
"size 1234",
`dim ${dim}`,
`filename ${filename}`,
...(thumb ? [`thumb ${thumb}`] : []),
];
}
async function installNoDimImageRoutes(page: Page) {
await page.route("https://example.com/e2e/gallery-*.png", (route) => {
const requestedUrl = route.request().url();
const isPortrait = requestedUrl.includes("portrait");
const isSecond = requestedUrl.includes("second");
const width = isPortrait ? 120 : 320;
const height = isPortrait ? 320 : 120;
const fill = isSecond ? "#a78bfa" : isPortrait ? "#f4b860" : "#4aa3df";
route.fulfill({
body: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="100%" height="100%" fill="${fill}"/></svg>`,
contentType: "image/svg+xml",
});
});
}
async function getLightboxFrameBox(page: Page) {
const box = await page.locator("[data-image-lightbox-frame]").boundingBox();
if (!box) {
throw new Error("Expected lightbox frame to have a layout box");
}
return box;
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
uploadDescriptors: [
{
url: `http://localhost:3000/media/${IMAGE_SHAS[0]}.png`,
sha256: IMAGE_SHAS[0],
size: 1234,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "160x100",
filename: "first.png",
},
{
url: `http://localhost:3000/media/${IMAGE_SHAS[1]}.png`,
sha256: IMAGE_SHAS[1],
size: 2345,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "100x160",
filename: "second.png",
},
{
url: `http://localhost:3000/media/${IMAGE_SHAS[2]}.png`,
sha256: IMAGE_SHAS[2],
size: 3456,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "140x140",
filename: "third.png",
},
],
});
});
test("image bundle lightbox navigates as a gallery", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill("gallery bundle");
await page.getByRole("button", { name: "Attach file" }).click();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const row = page
.getByTestId("message-row")
.filter({ hasText: "gallery bundle" })
.last();
await expect(row).toBeVisible();
const triggers = row.getByTestId("message-image-lightbox-trigger");
await expect(triggers).toHaveCount(3);
const mosaic = row.locator("[data-image-mosaic]");
await expect(mosaic).toHaveAttribute("data-image-mosaic-count", "3");
await expectSmoothCorners(mosaic);
const mosaicCornerRadius = await mosaic.evaluate(
(element) => window.getComputedStyle(element).borderTopLeftRadius,
);
const mosaicBox = await mosaic.boundingBox();
const firstBox = await triggers.first().boundingBox();
const secondBox = await triggers.nth(1).boundingBox();
const thirdBox = await triggers.nth(2).boundingBox();
if (!mosaicBox || !firstBox || !secondBox || !thirdBox) {
throw new Error("Expected image mosaic tiles to have layout boxes");
}
expect(mosaicBox.width).toBeCloseTo(512, 0);
expect(firstBox.height).toBeGreaterThan(secondBox.height * 1.8);
expect(firstBox.height).toBeCloseTo(
thirdBox.y + thirdBox.height - secondBox.y,
0,
);
expect(secondBox.x).toBeCloseTo(thirdBox.x, 0);
expect(secondBox.y).toBeLessThan(thirdBox.y);
await expectCornerRadiusPx(mosaic, 16);
await expectCornerRadiusPx(triggers.first(), 0);
await expect(triggers.first().locator("img")).toHaveCSS(
"object-fit",
"cover",
);
await expectCornerRadiusPx(triggers.first().locator("img"), 0);
await triggers.first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src*="${IMAGE_SHAS[0]}"]`)).toBeVisible();
const lightboxSurface = page
.locator("[data-image-lightbox-frame] > div > div")
.first();
await expectCornerRadiusPx(lightboxSurface, 16);
await expectSmoothCorners(lightboxSurface);
await expect(page.getByRole("button", { name: "Zoom out" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Zoom in" })).toBeEnabled();
// Clicking the image zooms to the secondary level instead of dismissing the
// dialog, and the clicked image point remains under the cursor.
await waitForAnimations(page);
const lightboxImage = dialog.locator(`img[src*="${IMAGE_SHAS[0]}"]`);
const initialImageBox = await lightboxImage.boundingBox();
if (!initialImageBox) {
throw new Error("Expected lightbox image to have a layout box");
}
const clickPoint = {
x: initialImageBox.x + initialImageBox.width * 0.25,
y: initialImageBox.y + initialImageBox.height * 0.35,
};
await page.mouse.click(clickPoint.x, clickPoint.y);
await expect(dialog).toBeVisible();
await expect(page.getByText("175%", { exact: true })).toBeVisible();
await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue(
"1.75",
);
await waitForAnimations(page);
const zoomedImageBox = await lightboxImage.boundingBox();
if (!zoomedImageBox) {
throw new Error("Expected zoomed lightbox image to have a layout box");
}
expect(zoomedImageBox.width).toBeCloseTo(initialImageBox.width * 1.75, 0);
expect(zoomedImageBox.height).toBeCloseTo(initialImageBox.height * 1.75, 0);
expect(
Math.abs(
zoomedImageBox.x + initialImageBox.width * 0.25 * 1.75 - clickPoint.x,
),
).toBeLessThan(2);
expect(
Math.abs(
zoomedImageBox.y + initialImageBox.height * 0.35 * 1.75 - clickPoint.y,
),
).toBeLessThan(2);
// A second image click toggles back to the centered 1× view.
await page.mouse.click(clickPoint.x, clickPoint.y);
await expect(dialog).toBeVisible();
await expect(page.getByText("100%", { exact: true })).toBeVisible();
await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue(
"1",
);
await page.getByRole("button", { name: "Zoom in" }).click();
await expect(dialog).toBeVisible();
await expect(page.getByText("115%", { exact: true })).toBeVisible();
await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue(
"1.15",
);
await expect(
page.getByRole("slider", { name: "Image zoom" }),
).toHaveAttribute("step", "0.15");
await page.getByRole("button", { name: "Zoom out" }).click();
await expect(dialog).toBeVisible();
await expect(page.getByText("100%", { exact: true })).toBeVisible();
await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue(
"1",
);
await expect(
page.getByRole("button", { name: "Previous image" }),
).toHaveCount(0);
await page.getByRole("button", { name: "Next image" }).click();
await expect(dialog.locator(`img[src*="${IMAGE_SHAS[1]}"]`)).toBeVisible();
await expect(
page.getByRole("button", { name: "Previous image" }),
).toBeVisible();
await page.keyboard.press("ArrowRight");
const currentLightboxImage = dialog.locator(`img[src*="${IMAGE_SHAS[2]}"]`);
const lightboxFrame = page.locator("[data-image-lightbox-frame]");
await expect(currentLightboxImage).toBeVisible();
await expect(currentLightboxImage).toHaveCSS("object-fit", "contain");
await expect(page.getByRole("button", { name: "Next image" })).toHaveCount(0);
const currentThumbnailBox = await triggers
.nth(2)
.locator("img")
.boundingBox();
if (!currentThumbnailBox) {
throw new Error("Expected current gallery thumbnail to have a layout box");
}
await page.waitForTimeout(500);
await page.mouse.click(20, 20);
await expect(currentLightboxImage).toHaveCSS("object-fit", "cover");
const closingFrameStyle = await lightboxFrame.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Expected HTML lightbox frame");
}
return {
borderBottomLeftRadius: element.style.borderBottomLeftRadius,
borderBottomRightRadius: element.style.borderBottomRightRadius,
borderTopLeftRadius: element.style.borderTopLeftRadius,
borderTopRightRadius: element.style.borderTopRightRadius,
height: Number.parseFloat(element.style.height),
left: Number.parseFloat(element.style.left),
top: Number.parseFloat(element.style.top),
transitionProperty: element.style.transitionProperty,
width: Number.parseFloat(element.style.width),
};
});
expect(closingFrameStyle.borderTopLeftRadius).toBe("0px");
expect(closingFrameStyle.borderTopRightRadius).toBe("0px");
expect(closingFrameStyle.borderBottomLeftRadius).toBe("0px");
expect(closingFrameStyle.borderBottomRightRadius).toBe(mosaicCornerRadius);
expect(closingFrameStyle.transitionProperty).toContain("border-radius");
const lightboxSurfaceStyle = await page
.locator("[data-image-lightbox-frame] > div > div")
.first()
.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Expected HTML lightbox surface");
}
return {
borderBottomRightRadius: element.style.borderBottomRightRadius,
borderTopLeftRadius: element.style.borderTopLeftRadius,
transitionProperty: element.style.transitionProperty,
};
});
expect(lightboxSurfaceStyle.borderTopLeftRadius).toBe("0px");
expect(lightboxSurfaceStyle.borderBottomRightRadius).toBe(mosaicCornerRadius);
expect(lightboxSurfaceStyle.transitionProperty).toBe("border-radius");
expect(Math.abs(closingFrameStyle.left - currentThumbnailBox.x)).toBeLessThan(
2,
);
expect(Math.abs(closingFrameStyle.top - currentThumbnailBox.y)).toBeLessThan(
2,
);
expect(
Math.abs(closingFrameStyle.width - currentThumbnailBox.width),
).toBeLessThan(2);
expect(
Math.abs(closingFrameStyle.height - currentThumbnailBox.height),
).toBeLessThan(2);
});
test("thread lightbox navigates images across messages", async ({ page }) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const threadTimestamp = Math.floor(Date.now() / 1000) - 10;
const rootId = await emitMockMessage(
page,
["thread gallery root", `![root](${NO_DIM_WIDE_URL})`].join("\n"),
undefined,
threadTimestamp,
);
await emitMockMessage(
page,
["thread gallery reply one", `![reply one](${NO_DIM_WIDE_URL})`].join("\n"),
rootId,
threadTimestamp + 1,
);
await emitMockMessage(
page,
["thread gallery reply two", `![reply two](${NO_DIM_SECOND_URL})`].join(
"\n",
),
rootId,
threadTimestamp + 2,
);
const threadSummary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
);
await expect(threadSummary).toBeVisible();
await threadSummary.click();
const panel = page.getByTestId("message-thread-panel");
await expect(panel).toBeVisible();
const replies = panel.getByTestId("message-thread-replies");
const finalImage = replies
.getByTestId("message-row")
.filter({ hasText: "thread gallery reply two" })
.getByTestId("message-image-lightbox-trigger");
await finalImage.click();
const dialog = page.getByRole("dialog");
const position = dialog.getByRole("status");
await expect(dialog.locator(`img[src="${NO_DIM_SECOND_URL}"]`)).toBeVisible();
await expect(position).toHaveText("3 / 3");
await expect(position).toHaveAttribute("aria-label", "Image 3 of 3");
await expect(dialog.getByRole("button", { name: "Next image" })).toHaveCount(
0,
);
await page.keyboard.press("ArrowLeft");
await expect(dialog.getByRole("img", { name: "reply one" })).toBeVisible();
await expect(position).toHaveText("2 / 3");
const replyOneImage = replies
.getByTestId("message-row")
.filter({ hasText: "thread gallery reply one" })
.getByTestId("message-image-lightbox-trigger");
const replyOneThumbnailBox = await replyOneImage.locator("img").boundingBox();
if (!replyOneThumbnailBox) {
throw new Error("Expected duplicate reply thumbnail to have a layout box");
}
const lightboxFrame = page.locator("[data-image-lightbox-frame]");
await page.waitForTimeout(500);
await page.mouse.click(20, 20);
const closingFrameBox = await lightboxFrame.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Expected HTML lightbox frame");
}
return {
height: Number.parseFloat(element.style.height),
left: Number.parseFloat(element.style.left),
top: Number.parseFloat(element.style.top),
width: Number.parseFloat(element.style.width),
};
});
expect(Math.abs(closingFrameBox.left - replyOneThumbnailBox.x)).toBeLessThan(
2,
);
expect(Math.abs(closingFrameBox.top - replyOneThumbnailBox.y)).toBeLessThan(
2,
);
expect(
Math.abs(closingFrameBox.width - replyOneThumbnailBox.width),
).toBeLessThan(2);
expect(
Math.abs(closingFrameBox.height - replyOneThumbnailBox.height),
).toBeLessThan(2);
await expect(dialog).toHaveCount(0);
await finalImage.click();
await expect(position).toHaveText("3 / 3");
await page.keyboard.press("ArrowLeft");
await expect(position).toHaveText("2 / 3");
await dialog.getByRole("button", { name: "Previous image" }).click();
await expect(dialog.getByRole("img", { name: "root" })).toBeVisible();
await expect(position).toHaveText("1 / 3");
await expect(
dialog.getByRole("button", { name: "Previous image" }),
).toHaveCount(0);
});
test("thread gallery includes only currently rendered media", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const timestamp = Math.floor(Date.now() / 1000) - 10;
const rootId = await emitMockMessage(
page,
["rendered gallery root", `![root](${NO_DIM_WIDE_URL})`].join("\n"),
undefined,
timestamp,
);
const branchId = await emitMockMessage(
page,
["rendered branch", `![branch](${NO_DIM_PORTRAIT_URL})`].join("\n"),
rootId,
timestamp + 1,
);
await emitMockMessage(
page,
["collapsed child", `![child](${NO_DIM_SECOND_URL})`].join("\n"),
branchId,
timestamp + 2,
);
await emitMockMessage(
page,
["rendered sibling", `![sibling](${NO_DIM_WIDE_URL})`].join("\n"),
rootId,
timestamp + 3,
);
await page
.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
)
.click();
const replies = page
.getByTestId("message-thread-panel")
.getByTestId("message-thread-replies");
await expect(
replies.getByTestId("message-row").filter({ hasText: "collapsed child" }),
).toHaveCount(0);
await replies
.getByTestId("message-row")
.filter({ hasText: "rendered sibling" })
.getByTestId("message-image-lightbox-trigger")
.click();
const dialog = page.getByRole("dialog");
const position = dialog.getByRole("status");
await expect(position).toHaveText("3 / 3");
await dialog.getByRole("button", { name: "Previous image" }).click();
await expect(dialog.getByRole("img", { name: "branch" })).toBeVisible();
await expect(position).toHaveText("2 / 3");
await expect(dialog.getByRole("img", { name: "child" })).toHaveCount(0);
});
test("preview-first galleries retain Markdown image actions", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/6705";
await installMockBridge(page, {
uploadDescriptors: [
{
url: `http://localhost:3000/media/${IMAGE_SHAS[2]}.png`,
sha256: IMAGE_SHAS[2],
size: 3456,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "140x140",
filename: "preview.png",
},
],
});
await installNoDimImageRoutes(page);
await page.route("http://127.0.0.1:54321/media/**", (route) =>
route.fulfill({
body: '<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140"><rect width="100%" height="100%" fill="#60a5fa"/></svg>',
contentType: "image/svg+xml",
}),
);
await page.addInitScript(() => {
localStorage.setItem("buzz.appearance.linkPreviewStyle", "rich");
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await waitForMockLiveSubscription(page, "general");
const timestamp = Math.floor(Date.now() / 1000) - 10;
const rootId = await emitMockMessage(
page,
"preview action root",
undefined,
timestamp,
);
await emitMockMessage(
page,
[
`preview action reply ${previewUrl}`,
`![body image](${NO_DIM_WIDE_URL})`,
].join("\n"),
rootId,
timestamp + 1,
[
[
"link-preview",
"snapshot",
"1",
previewUrl,
"Preview title",
"Example",
"Preview description",
`http://localhost:3000/media/${IMAGE_SHAS[2]}.png`,
IMAGE_SHAS[2],
"",
"",
],
],
);
await page
.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
)
.click();
const reply = page
.getByTestId("message-thread-panel")
.getByTestId("message-row")
.filter({ hasText: "preview action reply" });
await reply.getByRole("button", { name: /Zoom image: Preview from/ }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("status")).toHaveText("2 / 2");
await dialog.getByRole("button", { name: "Previous image" }).click();
const bodyImage = dialog.getByRole("img", { name: "body image" });
await expect(bodyImage).toBeVisible();
await dialog.getByRole("button", { name: "Download image" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("download_image");
await bodyImage.click({ button: "right" });
await dialog.getByRole("button", { name: "Copy image" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("copy_image_to_clipboard");
});
test("adjacent channel messages remain separate image galleries", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(
page,
["separate gallery one", `![one](${NO_DIM_WIDE_URL})`].join("\n"),
);
await emitMockMessage(
page,
["separate gallery two", `![two](${NO_DIM_PORTRAIT_URL})`].join("\n"),
);
const firstRow = page
.getByTestId("message-row")
.filter({ hasText: "separate gallery one" })
.last();
await firstRow.getByTestId("message-image-lightbox-trigger").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("status")).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Next image" })).toHaveCount(
0,
);
});
test("hidden spoiler images are excluded from gallery navigation until revealed", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ content, extraTags }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
extraTags?: string[][];
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content,
extraTags,
});
},
{
content: [
"spoiler gallery",
`![visible](${SPOILER_VISIBLE_URL})`,
`||![hidden](${SPOILER_HIDDEN_URL})||`,
].join("\n"),
extraTags: [
imageImetaTag({
dim: "160x100",
filename: "visible.png",
sha: SPOILER_VISIBLE_SHA,
url: SPOILER_VISIBLE_URL,
}),
imageImetaTag({
dim: "100x160",
filename: "hidden.png",
sha: SPOILER_HIDDEN_SHA,
url: SPOILER_HIDDEN_URL,
}),
],
},
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "spoiler gallery" })
.last();
await expect(row).toBeVisible();
const visibleImage = row.locator(`img[src*="${SPOILER_VISIBLE_SHA}"]`);
await visibleImage.scrollIntoViewIfNeeded();
await waitForAnimations(page);
await visibleImage.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(page.getByRole("button", { name: "Next image" })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
const spoiler = row.locator(".buzz-spoiler[data-spoiler]").first();
await expect(spoiler).toHaveAttribute("data-revealed", "false");
await spoiler.click();
await expect(spoiler).toHaveAttribute("data-revealed", "true");
// The gallery snapshots visible images when opened; the state attribute
// changes before the spoiler image finishes its opacity transition.
await expect(spoiler.locator(`img[src*="${SPOILER_HIDDEN_SHA}"]`)).toHaveCSS(
"opacity",
"1",
);
await visibleImage.click();
await expect(dialog).toBeVisible();
await expect(page.getByRole("button", { name: "Next image" })).toBeVisible();
});
test("message images load a thumbnail before requesting the original", async ({
page,
}) => {
let fullRequested = false;
let releaseThumbnail: (() => void) | undefined;
let releaseFull: (() => void) | undefined;
const thumbnailGate = new Promise<void>((resolve) => {
releaseThumbnail = resolve;
});
const fullGate = new Promise<void>((resolve) => {
releaseFull = resolve;
});
const svg = (fill: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="100%" height="100%" fill="${fill}"/></svg>`;
await page.route(PROGRESSIVE_THUMB_URL, async (route) => {
await thumbnailGate;
await route.fulfill({ body: svg("#f4b860"), contentType: "image/svg+xml" });
});
await page.route(PROGRESSIVE_URL, async (route) => {
fullRequested = true;
await fullGate;
await route.fulfill({ body: svg("#4aa3df"), contentType: "image/svg+xml" });
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ content, extraTags }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
extraTags: string[][];
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content,
extraTags,
});
},
{
content: `progressive image\n![progressive](${PROGRESSIVE_URL})`,
extraTags: [
imageImetaTag({
dim: "320x200",
filename: "progressive.png",
sha: "f".repeat(64),
thumb: PROGRESSIVE_THUMB_URL,
url: PROGRESSIVE_URL,
}),
],
},
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "progressive image" })
.last();
const trigger = row.getByTestId("message-image-lightbox-trigger");
await expect(
trigger.locator(`img[src="${PROGRESSIVE_THUMB_URL}"]`),
).toHaveCount(1);
await expect(trigger.locator(`img[src="${PROGRESSIVE_URL}"]`)).toHaveCount(0);
expect(fullRequested).toBe(false);
const before = await trigger.boundingBox();
const rowBefore = await row.boundingBox();
releaseThumbnail?.();
const full = trigger.locator(`img[src="${PROGRESSIVE_URL}"]`);
await expect(full).toHaveCount(1);
await expect(full).toHaveClass(/opacity-0/);
expect(fullRequested).toBe(true);
releaseFull?.();
await expect(full).toBeVisible();
await expect(full).not.toHaveClass(/opacity-0/);
expect(await trigger.boundingBox()).toEqual(before);
expect(await row.boundingBox()).toEqual(rowBefore);
});
test("gallery items without imeta dimensions keep their thumbnail aspect ratio", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ content }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content,
});
},
{
content: [
"no dim gallery",
`![wide](${NO_DIM_WIDE_URL})`,
`![portrait](${NO_DIM_PORTRAIT_URL})`,
].join("\n"),
},
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "no dim gallery" })
.last();
await expect(row).toBeVisible();
await expect(row.locator(`img[src="${NO_DIM_WIDE_URL}"]`)).toBeVisible();
await expect(row.locator(`img[src="${NO_DIM_PORTRAIT_URL}"]`)).toBeVisible();
await row.locator(`img[src="${NO_DIM_WIDE_URL}"]`).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${NO_DIM_WIDE_URL}"]`)).toBeVisible();
await page.waitForTimeout(350);
const wideFrameBox = await getLightboxFrameBox(page);
expect(wideFrameBox.width / wideFrameBox.height).toBeGreaterThan(2);
await page.getByRole("button", { name: "Next image" }).click();
await expect(
dialog.locator(`img[src="${NO_DIM_PORTRAIT_URL}"]`),
).toBeVisible();
await page.waitForTimeout(350);
const portraitFrameBox = await getLightboxFrameBox(page);
expect(portraitFrameBox.width / portraitFrameBox.height).toBeLessThan(0.6);
});
test("forum markdown images use the markdown root as their gallery scope", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await expect
.poll(() => {
return page.evaluate(() => {
return (
typeof (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function"
);
});
})
.toBe(true);
const postId = await page.evaluate(
({ content }) => {
const event = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
kind: number;
}) => { id: string };
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "watercooler",
content,
kind: 45001,
});
return event?.id ?? null;
},
{
content: [
"forum gallery scope",
`![wide](${NO_DIM_WIDE_URL})`,
`![portrait](${NO_DIM_PORTRAIT_URL})`,
].join("\n"),
},
);
expect(postId).not.toBeNull();
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await page
.getByRole("button")
.filter({ hasText: "forum gallery scope" })
.first()
.getByText("forum gallery scope")
.click();
const threadPost = page.locator(`[data-forum-event-id="${postId}"]`);
await expect(threadPost).toBeVisible();
const triggers = threadPost.getByTestId("message-image-lightbox-trigger");
await expect(triggers).toHaveCount(2);
await expect
.poll(() =>
triggers.first().evaluate((trigger) => {
return trigger.closest("[data-testid='message-row']") !== null;
}),
)
.toBe(false);
await triggers.first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${NO_DIM_WIDE_URL}"]`)).toBeVisible();
await expect(
page.getByRole("button", { name: "Previous image" }),
).toHaveCount(0);
await expect(page.getByRole("button", { name: "Next image" })).toBeVisible();
await page.getByRole("button", { name: "Next image" }).click();
await expect(
dialog.locator(`img[src="${NO_DIM_PORTRAIT_URL}"]`),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Previous image" }),
).toBeVisible();
});
test("multi-image mosaics keep a fixed width and grow by rows", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const urls = Array.from(
{ length: 5 },
(_, index) =>
`https://example.com/e2e/gallery-${index % 2 === 0 ? "wide" : "portrait"}.png?item=${index}`,
);
await page.evaluate((imageUrls) => {
const emit = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
for (const count of [2, 4, 5]) {
emit?.({
channelName: "general",
content: [
`${count} image mosaic`,
...imageUrls.slice(0, count).map((url) => `![image](${url})`),
].join("\n"),
});
}
}, urls);
const mosaics: Array<{ count: number; height: number; width: number }> = [];
for (const count of [2, 4, 5]) {
const row = page
.getByTestId("message-row")
.filter({ hasText: `${count} image mosaic` })
.last();
const mosaic = row.locator("[data-image-mosaic]");
await expect(mosaic).toHaveAttribute(
"data-image-mosaic-count",
String(count),
);
const box = await mosaic.boundingBox();
if (!box) throw new Error(`Expected ${count}-image mosaic layout box`);
mosaics.push({ count, height: box.height, width: box.width });
}
expect(mosaics[0].width).toBeCloseTo(mosaics[1].width, 0);
expect(mosaics[1].width).toBeCloseTo(mosaics[2].width, 0);
expect(mosaics[1].height).toBeGreaterThan(mosaics[0].height);
expect(mosaics[2].height).toBeGreaterThan(mosaics[1].height);
expect(mosaics[2].height - mosaics[1].height).toBeCloseTo(
mosaics[0].height + 6,
0,
);
});
test("image mosaic screenshot", async ({ page }) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ portraitUrl, secondUrl, wideUrl }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: [
"Weekend photo dump",
`![Coastal overlook](${wideUrl})`,
`![Boardwalk detail](${portraitUrl})`,
`![Golden hour](${secondUrl})`,
].join("\n"),
});
},
{
portraitUrl: NO_DIM_PORTRAIT_URL,
secondUrl: NO_DIM_SECOND_URL,
wideUrl: NO_DIM_WIDE_URL,
},
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "Weekend photo dump" })
.last();
await expect(row.locator("[data-image-mosaic] img")).toHaveCount(3);
await waitForAnimations(page);
await row.screenshot({
path: "test-results/image-mosaic/three-image-mosaic.png",
});
});
test("mosaic image context menu is portaled outside the clipped gallery", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill("mosaic context menu");
await page.getByRole("button", { name: "Attach file" }).click();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const row = page
.getByTestId("message-row")
.filter({ hasText: "mosaic context menu" })
.last();
const mosaic = row.locator("[data-image-mosaic]");
const trigger = row.getByTestId("message-image-lightbox-trigger").last();
await expect(mosaic).toBeVisible();
await trigger.click({ button: "right" });
const menu = page.locator("[data-image-context-menu]");
await expect(menu).toBeVisible();
await expect(page.getByRole("button", { name: "Copy image" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Download image" }),
).toBeVisible();
await expect(mosaic.locator("[data-image-context-menu]")).toHaveCount(0);
expect(
await menu.evaluate((element) => element.parentElement === document.body),
).toBe(true);
const mosaicBox = await mosaic.boundingBox();
const menuBox = await menu.boundingBox();
if (!mosaicBox || !menuBox) {
throw new Error("Expected mosaic and image context menu layout boxes");
}
expect(menuBox.x + menuBox.width).toBeGreaterThan(
mosaicBox.x + mosaicBox.width,
);
});
test("lightbox image context menu stays inside the dialog focus scope", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill("lightbox context menu");
await page.getByRole("button", { name: "Attach file" }).click();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const row = page
.getByTestId("message-row")
.filter({ hasText: "lightbox context menu" })
.last();
await row.getByTestId("message-image-lightbox-trigger").first().click();
const dialog = page.getByRole("dialog");
const lightboxImage = dialog.locator(`img[src*="${IMAGE_SHAS[0]}"]`);
await expect(lightboxImage).toBeVisible();
await lightboxImage.click({ button: "right" });
const menu = dialog.locator("[data-image-context-menu]");
const copyButton = menu.getByRole("button", { name: "Copy image" });
const downloadButton = menu.getByRole("button", { name: "Download image" });
await expect(menu).toBeVisible();
await expect(page.locator("body > [data-image-context-menu]")).toHaveCount(0);
await dialog.focus();
await page.keyboard.press("Shift+Tab");
await expect(downloadButton).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(copyButton).toBeFocused();
await page.keyboard.press("Tab");
await expect(downloadButton).toBeFocused();
await page.keyboard.press("Tab");
await expect(
dialog.getByRole("button", { name: "Next image" }),
).toBeFocused();
});
test("right-click image shows Copy image and invokes copy command", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill("copy me");
await page.getByRole("button", { name: "Attach file" }).click();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const row = page
.getByTestId("message-row")
.filter({ hasText: "copy me" })
.last();
const trigger = row.getByTestId("message-image-lightbox-trigger").first();
await expect(trigger).toBeVisible();
await trigger.click({ button: "right" });
const copyButton = page.getByRole("button", { name: "Copy image" });
await expect(copyButton).toBeVisible();
await expect(
page.getByRole("button", { name: "Download image" }),
).toBeVisible();
// The image menu carries the shared generic marker plus its own image-
// specific attribute, and never the link/video attributes — so e2e locators
// can target one surface without aliasing another.
await expect(page.locator("[data-media-context-menu]")).toBeVisible();
await expect(page.locator("[data-image-context-menu]")).toBeVisible();
await expect(page.locator("[data-link-context-menu]")).toHaveCount(0);
await expect(page.locator("[data-video-context-menu]")).toHaveCount(0);
await copyButton.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("copy_image_to_clipboard");
});