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 { 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: ``, 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: '', 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(); await row.locator(`img[src*="${SPOILER_VISIBLE_SHA}"]`).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"); await row.locator(`img[src*="${SPOILER_VISIBLE_SHA}"]`).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((resolve) => { releaseThumbnail = resolve; }); const fullGate = new Promise((resolve) => { releaseFull = resolve; }); const svg = (fill: string) => ``; 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"); });