From 8c85755cb94846a7498eae9004e47786bc668198 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:39:32 +0100 Subject: [PATCH] test(vrt): settle Firefox card enter before social-surface captures MarketplaceSocialSurfaces hot-marketplace-modules-desktop was re-pinned when Firefox composited the card-enter from-frame (opacity 0, translateY(18px)) and the boots cover was missing. The capture now cancels that animation, pins the settled frame, and waits until the cover has a real layout box. The existing Linux baselines are unchanged. --- src/test-utils/vrt.social-settle.test.ts | 54 ++++++++ src/test-utils/vrt.social-settle.ts | 123 ++++++++++++++++++ .../MarketplaceSocialSurfaces.vrt.test.tsx | 22 +++- 3 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 src/test-utils/vrt.social-settle.test.ts create mode 100644 src/test-utils/vrt.social-settle.ts diff --git a/src/test-utils/vrt.social-settle.test.ts b/src/test-utils/vrt.social-settle.test.ts new file mode 100644 index 000000000..8042d811a --- /dev/null +++ b/src/test-utils/vrt.social-settle.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { COVER_MIN_LAYOUT_PX, coverIsPainted, paintedCoverCount, SETTLED_CARD_STYLE } from './vrt.social-settle'; + +describe('coverIsPainted', () => { + it('rejects the intrinsic data-URL size and an incomplete decode', () => { + expect( + coverIsPainted({ + complete: true, + naturalWidth: 8, + width: 8, + height: 8, + }), + ).toBe(false); + expect( + coverIsPainted({ + complete: false, + naturalWidth: 0, + width: 280, + height: 280, + }), + ).toBe(false); + expect(COVER_MIN_LAYOUT_PX).toBe(64); + }); + + it('accepts a decoded cover that has stretched into the card frame', () => { + expect( + coverIsPainted({ + complete: true, + naturalWidth: 8, + width: 280, + height: 280, + }), + ).toBe(true); + }); + + it('counts only painted covers', () => { + expect( + paintedCoverCount([ + { complete: true, naturalWidth: 8, width: 8, height: 8 }, + { complete: true, naturalWidth: 8, width: 280, height: 280 }, + ]), + ).toBe(1); + }); +}); + +describe('SETTLED_CARD_STYLE', () => { + it('pins the enter animation end state, not the from-frame', () => { + expect(SETTLED_CARD_STYLE).toEqual({ + animation: 'none', + opacity: '1', + transform: 'none', + }); + }); +}); diff --git a/src/test-utils/vrt.social-settle.ts b/src/test-utils/vrt.social-settle.ts new file mode 100644 index 000000000..9a5430e59 --- /dev/null +++ b/src/test-utils/vrt.social-settle.ts @@ -0,0 +1,123 @@ +/** + * Firefox-only settle for MarketplaceSocialSurfaces captures. + * + * `.marketplace-card-enter` is `opacity: 0` + `translateY(18px)` during its + * fill delay. The suite stylesheet sets `animation: none`, and Playwright + * finishes finite animations before a screenshot, but Firefox under load + * still composites that from-frame. The boots cover then stays invisible + * and the gradient icon shows through. That is the hot-marketplace-modules + * desktop delta (the re-pinned baseline has the cover; the previous one + * does not). Pin the settled frame on the card itself, then wait until each + * expected cover has a real layout box — an 8×8 data URL that has not + * stretched into the media frame does not count. + */ + +export const SETTLED_CARD_STYLE = { + animation: 'none', + opacity: '1', + transform: 'none', +} as const; + +/** Below this, the element is still the intrinsic data-URL size, not the card frame. */ +export const COVER_MIN_LAYOUT_PX = 64; + +export interface CoverBox { + complete: boolean; + naturalWidth: number; + width: number; + height: number; +} + +export function coverIsPainted(image: CoverBox): boolean { + return ( + image.complete && + image.naturalWidth > 0 && + image.width >= COVER_MIN_LAYOUT_PX && + image.height >= COVER_MIN_LAYOUT_PX + ); +} + +export function paintedCoverCount(images: readonly CoverBox[]): number { + return images.filter(coverIsPainted).length; +} + +function nextFrame(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +function coverBox(image: HTMLImageElement): CoverBox { + const box = image.getBoundingClientRect(); + return { + complete: image.complete, + naturalWidth: image.naturalWidth, + width: box.width, + height: box.height, + }; +} + +export async function settleMarketplaceSocialCapture(expectedCovers: number, timeoutMs = 2_000): Promise { + const root = document.querySelector('[data-testid="vrt-root"]'); + if (!(root instanceof HTMLElement)) { + throw new Error('VRT social settle: no [data-testid="vrt-root"] element'); + } + + if (typeof root.getAnimations === 'function') { + for (const animation of root.getAnimations({ subtree: true })) { + try { + animation.cancel(); + } catch { + // Already finished or not cancelable. + } + } + } + + for (const card of root.querySelectorAll('.marketplace-card-enter')) { + if (!(card instanceof HTMLElement)) continue; + card.style.setProperty('animation', SETTLED_CARD_STYLE.animation, 'important'); + card.style.setProperty('opacity', SETTLED_CARD_STYLE.opacity, 'important'); + card.style.setProperty('transform', SETTLED_CARD_STYLE.transform, 'important'); + } + + if (document.fonts?.load) { + await Promise.all( + ['400 12px "Inter Tight"', '500 11px "Inter Tight"', '700 16px "Inter Tight"', '700 20px "Inter Tight"'].map( + (font) => document.fonts.load(font).catch(() => []), + ), + ); + await document.fonts.ready; + } + + // Flush layout after the inline settle so Firefox measures the stretched cover. + root.getBoundingClientRect(); + + const deadline = performance.now() + timeoutMs; + let images: HTMLImageElement[] = []; + while (performance.now() < deadline) { + images = [...root.querySelectorAll('img')].filter( + (node): node is HTMLImageElement => node instanceof HTMLImageElement, + ); + if (paintedCoverCount(images.map(coverBox)) >= expectedCovers) { + await nextFrame(); + await nextFrame(); + images = [...root.querySelectorAll('img')].filter( + (node): node is HTMLImageElement => node instanceof HTMLImageElement, + ); + if (paintedCoverCount(images.map(coverBox)) >= expectedCovers) { + return; + } + } + await nextFrame(); + } + + const detail = images + .map((image) => { + const box = coverBox(image); + return `${image.currentSrc || image.src} complete=${box.complete} naturalWidth=${box.naturalWidth} box=${box.width.toFixed(1)}x${box.height.toFixed(1)}`; + }) + .join('; '); + throw new Error( + `VRT social settle: expected ${expectedCovers} painted cover(s) within ${timeoutMs}ms, saw ${paintedCoverCount(images.map(coverBox))}. ${detail}`, + ); +} diff --git a/src/test/vrt/marketplace/MarketplaceSocialSurfaces.vrt.test.tsx b/src/test/vrt/marketplace/MarketplaceSocialSurfaces.vrt.test.tsx index 0f4eaea6b..2d921a9a0 100644 --- a/src/test/vrt/marketplace/MarketplaceSocialSurfaces.vrt.test.tsx +++ b/src/test/vrt/marketplace/MarketplaceSocialSurfaces.vrt.test.tsx @@ -1,6 +1,7 @@ // Intentional import order — browser-mode mock factories rely on stable aliases. /* eslint-disable simple-import-sort/imports */ import { describe, expect, it, vi } from 'vitest'; +import { settleMarketplaceSocialCapture } from '@/test-utils/vrt.social-settle'; import { renderForVRT, VRT_ROOT_TESTID } from '@/test-utils/vrt'; import { VRT_VIEWPORT_DESKTOP, VRT_VIEWPORT_MOBILE } from '@/test-utils/vrt.viewports'; import { MarketplaceFollowedSellersShelf } from '@/organisms/Marketplace/MarketplaceFollowedSellersShelf'; @@ -166,6 +167,15 @@ function SurfaceHost({ children }: { children: React.ReactNode }) { ); } +async function expectSocialScreenshot( + screen: Awaited>, + name: string, + expectedCovers: number, +) { + await settleMarketplaceSocialCapture(expectedCovers); + await expect(screen.getByTestId(VRT_ROOT_TESTID)).toMatchScreenshot(name); +} + describe('Marketplace social surfaces — visual regression', () => { it('renders the followed-sellers shelf as a horizontal card strip at desktop viewport', async () => { const f = await fixtures; @@ -176,7 +186,7 @@ describe('Marketplace social surfaces — visual regression', () => { , { viewport: VRT_VIEWPORT_DESKTOP, disableHover: true }, ); - await expect(screen.getByTestId(VRT_ROOT_TESTID)).toMatchScreenshot('followed-sellers-shelf-desktop'); + await expectSocialScreenshot(screen, 'followed-sellers-shelf-desktop', 1); }); it('renders the followed-sellers shelf at mobile viewport with overflow cards off-screen', async () => { @@ -188,7 +198,7 @@ describe('Marketplace social surfaces — visual regression', () => { , { viewport: VRT_VIEWPORT_MOBILE, disableHover: true }, ); - await expect(screen.getByTestId(VRT_ROOT_TESTID)).toMatchScreenshot('followed-sellers-shelf-mobile'); + await expectSocialScreenshot(screen, 'followed-sellers-shelf-mobile', 1); }); it('renders nothing at all for the shelf when no followed seller has active listings', async () => { @@ -199,7 +209,7 @@ describe('Marketplace social surfaces — visual regression', () => { , { viewport: VRT_VIEWPORT_DESKTOP, disableHover: true }, ); - await expect(screen.getByTestId(VRT_ROOT_TESTID)).toMatchScreenshot('followed-sellers-shelf-absent'); + await expectSocialScreenshot(screen, 'followed-sellers-shelf-absent', 0); }); it('renders the Hot-page ending-soon and fresh-listings modules at desktop viewport', async () => { @@ -211,7 +221,7 @@ describe('Marketplace social surfaces — visual regression', () => { , { viewport: VRT_VIEWPORT_DESKTOP, disableHover: true }, ); - await expect(screen.getByTestId(VRT_ROOT_TESTID)).toMatchScreenshot('hot-marketplace-modules-desktop'); + await expectSocialScreenshot(screen, 'hot-marketplace-modules-desktop', 1); }); it('renders only the fresh-listings module when no auction has known end terms', async () => { @@ -223,7 +233,7 @@ describe('Marketplace social surfaces — visual regression', () => { , { viewport: VRT_VIEWPORT_DESKTOP, disableHover: true }, ); - await expect(screen.getByTestId(VRT_ROOT_TESTID)).toMatchScreenshot('hot-marketplace-fresh-only-desktop'); + await expectSocialScreenshot(screen, 'hot-marketplace-fresh-only-desktop', 1); }); it('renders nothing at all on Hot when the index has no listings', async () => { @@ -234,6 +244,6 @@ describe('Marketplace social surfaces — visual regression', () => { , { viewport: VRT_VIEWPORT_DESKTOP, disableHover: true }, ); - await expect(screen.getByTestId(VRT_ROOT_TESTID)).toMatchScreenshot('hot-marketplace-modules-absent'); + await expectSocialScreenshot(screen, 'hot-marketplace-modules-absent', 0); }); });