diff --git a/apps/website/e2e/responsive.spec.ts b/apps/website/e2e/responsive.spec.ts index c77e8e4ec7..6488170457 100644 --- a/apps/website/e2e/responsive.spec.ts +++ b/apps/website/e2e/responsive.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from '@playwright/test' import { expect } from '@playwright/test' import { test } from './fixtures/blockExternalMedia' @@ -47,4 +48,105 @@ test.describe('Mobile layout @mobile', () => { const mobileContainer = page.getByTestId('social-proof-mobile') await expect(mobileContainer).toBeVisible() }) + + test.describe('SocialProofBar seamless marquee', () => { + test.use({ contextOptions: { reducedMotion: 'no-preference' } }) + + test('mobile forward marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-mobile"] .animate-marquee' + ) + expectSeamlessForwardLoop(geometry) + }) + + test('mobile reverse marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-mobile"] .animate-marquee-reverse' + ) + expectSeamlessReverseLoop(geometry) + }) + }) }) + +test.describe('Desktop SocialProofBar @smoke', () => { + test.use({ contextOptions: { reducedMotion: 'no-preference' } }) + + test.beforeEach(async ({ page }) => { + await page.goto('/') + }) + + test('desktop marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-desktop"] .animate-marquee' + ) + expectSeamlessForwardLoop(geometry) + }) +}) + +type MarqueeGeometry = { + copyWidths: number[] + startPositions: number[] + endPositions: number[] +} + +async function measureMarqueeLoopGeometry( + page: Page, + selector: string +): Promise { + await page.locator(selector).first().waitFor() + return page.evaluate((sel) => { + const tracks = Array.from( + document.querySelectorAll(sel) + ).slice(0, 2) + const firstAnimation = tracks[0]?.getAnimations()[0] + if (!firstAnimation) { + throw new Error(`No CSS animation found on ${sel}`) + } + const duration = firstAnimation.effect?.getTiming().duration + if (typeof duration !== 'number' || duration <= 1) { + throw new Error( + `Animation on ${sel} has unusable duration: ${String(duration)}` + ) + } + const setAllTimes = (time: number) => { + for (const track of tracks) { + for (const anim of track.getAnimations()) { + anim.currentTime = time + } + } + void document.body.offsetWidth + } + const readX = () => tracks.map((track) => track.getBoundingClientRect().x) + setAllTimes(0) + const startPositions = readX() + const copyWidths = tracks.map( + (track) => track.getBoundingClientRect().width + ) + setAllTimes(duration - 0.1) + const endPositions = readX() + return { copyWidths, startPositions, endPositions } + }, selector) +} + +function expectTwoMatchingCopies(geometry: MarqueeGeometry) { + const { copyWidths } = geometry + expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2) + expect(copyWidths[0]).toBeGreaterThan(0) + expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0) +} + +function expectSeamlessForwardLoop(geometry: MarqueeGeometry) { + expectTwoMatchingCopies(geometry) + // Copy 2 ends the cycle exactly where copy 1 started, so the restart + // (when copy 1 jumps back to its start position) is visually indistinguishable. + expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0) +} + +function expectSeamlessReverseLoop(geometry: MarqueeGeometry) { + expectTwoMatchingCopies(geometry) + // Reverse marquee: copy 1 ends the cycle where copy 2 started. + expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0) +} diff --git a/apps/website/src/components/common/SocialProofBarSection.vue b/apps/website/src/components/common/SocialProofBarSection.vue index 4cea331a62..e261a18591 100644 --- a/apps/website/src/components/common/SocialProofBarSection.vue +++ b/apps/website/src/components/common/SocialProofBarSection.vue @@ -14,23 +14,28 @@ const logos = [ 'Ubisoft' ] -const desktopLogos = Array.from({ length: 4 }, () => logos).flat() -const row1 = logos.slice(0, 6) -const mobileRow1 = [...row1, ...row1] -const row2 = logos.slice(6) -const mobileRow2 = [...row2, ...row2] +const mobileRow1Logos = logos.slice(0, 6) +const mobileRow2Logos = logos.slice(6)