import type { APIRequestContext, Locator, Page } from '@playwright/test' import { readFileSync } from 'node:fs' import { COLINHA_ROW_LAYOUT } from '@/lib/cardColinha' import { TEAM_CARD_NAME_BANNER } from '@/lib/cardModels' import { adminHeaders } from '../helpers/adminApi' import { waitForHomeHTML, waitForHomeSection } from '../helpers/homeIsr' import { seedTestUser } from '../helpers/seedUser' import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub' import { expect, test } from './fixtures/e2eTest' const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) // 4×3 opaque PNG — enough to exercise decode/cover/clamp without a binary fixture. const TEST_PHOTO_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVR4nGOQqzjxHxkzEBQAAMHJHF0PRQWkAAAAAElFTkSuQmCC', 'base64', ) const swipeLeft = async ( page: Page, box: { x: number; y: number; width: number; height: number }, ) => { const cdp = await page.context().newCDPSession(page) const y = Math.round(box.y + box.height / 2) const startX = Math.round(box.x + box.width * 0.82) const endX = Math.round(box.x + box.width * 0.12) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: startX, y }], }) for (let step = 1; step <= 5; step += 1) { const x = Math.round(startX + ((endX - startX) * step) / 5) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y }], }) await page.waitForTimeout(25) } await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) } /** * S14 — scans the name band (y[420..540]) of a card canvas: yellow pixel count * and leftmost x. The base masters have no yellow in this band, so the scan * sees only the drawn name. */ const readCardNameBand = (canvas: Locator) => canvas.evaluate((element) => { const node = element as HTMLCanvasElement const ctx = node.getContext('2d') if (!ctx) return { yellow: 0, minX: node.width } const band = ctx.getImageData(0, 420, node.width, 120).data let yellow = 0 let minX = node.width for (let index = 0; index < band.length; index += 4) { if (band[index] > 230 && band[index + 1] > 200 && band[index + 2] < 80) { yellow += 1 const x = (index / 4) % node.width if (x < minX) minX = x } } return { yellow, minX } }) /** S14 — the name starts on the `SOU` left border (x≈213 at 1080), not centered. */ const expectCardNameLeftAligned = async (canvas: Locator) => { await expect .poll(async () => { const { yellow, minX } = await readCardNameBand(canvas) return yellow > 1000 && minX >= 205 && minX <= 245 }) .toBe(true) } // C226 — the home sample renders the same card as the catalogue, so a video // piece asks the Central for its still before the play; the door answers 404 // until the self-heal lands and the browser logs that refusal. Declared here // because the sample is conditional (the serial frontendConteudos spec may // publish a video piece mid-run) and this spec must not depend on it. test.use({ expectedRequestFailurePaths: [/\/conteudos\/.+\/frame$/] }) test.describe('Frontend', () => { test('can go on homepage', async ({ page }) => { await page.goto('/') await expect(page).toHaveTitle(/Jorge Solla/) const heading = page.getByRole('heading', { level: 1, name: 'MAIS SAÚDE MAIS FUTURO', }) await expect(heading).toBeVisible() await expect(page.locator('[data-cta="primary"]')).toHaveAttribute( 'href', 'https://apoiar.me/jorgesolla', ) }) test('animates the novidades anchor and gives pointer feedback on campaign buttons', async ({ page, }) => { await page.goto('/') const scrollContainer = page.locator('[data-theme="campaign-site"]') const secondaryCta = page.locator('[data-cta="secondary"]') await expect(scrollContainer).toHaveCSS('scroll-behavior', 'smooth') await expect(secondaryCta).toHaveAttribute('href', '#novidades') await secondaryCta.hover() await expect .poll(() => secondaryCta.evaluate((element) => getComputedStyle(element).transform)) .toContain('1.05') const box = await secondaryCta.boundingBox() if (!box) throw new Error('O CTA secundário não ficou visível para testar o clique.') await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) await page.mouse.down() await expect(secondaryCta).toHaveCSS('opacity', '0.8') await page.mouse.move(0, 0) await page.mouse.up() await secondaryCta.click() await expect(page).toHaveURL(/#novidades$/) await expect(page.locator('#novidades')).toBeInViewport() }) test('keeps breathing room between the wrapped hero subtitle and CTAs on iPhone SE', async ({ page, }) => { await page.setViewportSize({ width: 320, height: 568 }) await page.goto('/') await expect(page).toHaveURL(/\/$/) const hero = page.locator('[data-home-section="hero"]') const subtitleBox = await hero.locator('p').first().boundingBox() const actionsBox = await hero.locator('[data-cta="secondary"]').locator('..').boundingBox() if (!subtitleBox || !actionsBox) throw new Error('O conteúdo principal do hero não foi medido.') expect(actionsBox.y - (subtitleBox.y + subtitleBox.height)).toBeGreaterThanOrEqual(16) }) test('adapts the campaign first fold fluidly across the reference viewport sizes', async ({ page, }) => { await page.goto('/') await expect(page).toHaveURL(/\/$/) const measureFirstFold = async (width: number, height: number) => { await page.setViewportSize({ width, height }) return page.evaluate(() => { const hero = document.querySelector('[data-home-section="hero"]') const proof = document.querySelector('[data-home-section="proof"]') const scrollContainer = document.querySelector('[data-theme="campaign-site"]') if (!hero || !proof || !scrollContainer) throw new Error('A primeira dobra não foi montada.') const box = (selector: string) => { const element = document.querySelector(selector) if (!element) throw new Error(`Elemento responsivo ausente: ${selector}`) const bounds = element.getBoundingClientRect() return { x: bounds.x, y: bounds.y, width: bounds.width } } const heroBounds = hero.getBoundingClientRect() return { heroHeight: heroBounds.height, firstFoldHeight: heroBounds.height + proof.getBoundingClientRect().height, horizontalOverflow: scrollContainer.scrollWidth - scrollContainer.clientWidth, title: box('.campaign-hero-title'), portrait: box('.campaign-hero-portrait'), secondaryAction: box('[data-cta="secondary"]'), } }) } const penpotBoards = [ { width: 393, height: 852, heroHeight: 745, firstFoldHeight: 833, title: [-5, 462, 392], portrait: [-6, 53, 422], secondaryAction: [108, 652, 145], }, { width: 768, height: 1024, heroHeight: 901, firstFoldHeight: 1024, title: [121, 497, 537], portrait: [193, 88, 422], secondaryAction: [214, 774, 158], }, { width: 1024, height: 900, heroHeight: 543, firstFoldHeight: 691, title: [585, 106, 392], portrait: [153, 26, 465], secondaryAction: [637, 349, 158], }, { width: 1280, height: 800, heroHeight: 676, firstFoldHeight: 800, title: [738, 127, 392], portrait: [239, 84, 533], secondaryAction: [790, 370, 158], }, { width: 1366, height: 768, heroHeight: 627, firstFoldHeight: 768, title: [762, 106, 392], portrait: [325, 9, 555], secondaryAction: [814, 349, 158], }, { width: 1920, height: 1080, heroHeight: 871, firstFoldHeight: 1080, title: [1093, 190, 607], portrait: [524, 91, 702], secondaryAction: [1325, 556, 174], }, ] for (const board of penpotBoards) { const measured = await measureFirstFold(board.width, board.height) expect(Math.abs(measured.heroHeight - board.heroHeight)).toBeLessThanOrEqual(2) expect(Math.abs(measured.firstFoldHeight - board.firstFoldHeight)).toBeLessThanOrEqual(2) expect(measured.horizontalOverflow).toBeLessThanOrEqual(1) for (const key of ['title', 'portrait', 'secondaryAction'] as const) { const [x, y, width] = board[key] expect(Math.abs(measured[key].x - x)).toBeLessThanOrEqual(8) expect(Math.abs(measured[key].y - y)).toBeLessThanOrEqual(2) expect(Math.abs(measured[key].width - width)).toBeLessThanOrEqual(2) } } const compact = [ await measureFirstFold(393, 852), await measureFirstFold(580, 900), await measureFirstFold(768, 1024), ] expect(compact[1].heroHeight).toBeGreaterThan(compact[0].heroHeight) expect(compact[1].heroHeight).toBeLessThan(compact[2].heroHeight) const standardDesktop = [ await measureFirstFold(1024, 900), await measureFirstFold(1152, 900), await measureFirstFold(1280, 800), ] expect(standardDesktop[1].heroHeight).toBeGreaterThan(standardDesktop[0].heroHeight) expect(standardDesktop[1].heroHeight).toBeLessThan(standardDesktop[2].heroHeight) const wideDesktop = [ await measureFirstFold(1366, 768), await measureFirstFold(1600, 900), await measureFirstFold(1920, 1080), ] expect(wideDesktop[1].heroHeight).toBeGreaterThan(wideDesktop[0].heroHeight) expect(wideDesktop[1].heroHeight).toBeLessThan(wideDesktop[2].heroHeight) const aspectBoundary = [ await measureFirstFold(1280, 747), await measureFirstFold(1280, 746), await measureFirstFold(1920, 1121), await measureFirstFold(1920, 1120), ] expect(Math.abs(aspectBoundary[0].heroHeight - aspectBoundary[1].heroHeight)).toBeLessThan(2) expect(Math.abs(aspectBoundary[2].heroHeight - aspectBoundary[3].heroHeight)).toBeLessThan(2) }) test('anchors the desktop portraits to the hero floor without height-dependent scaling', async ({ page, }) => { await page.goto('/') await expect(page).toHaveURL(/\/$/) const measureComposition = async (width: number, height: number) => { await page.setViewportSize({ width, height }) return page.evaluate(() => { const bounds = (selector: string) => { const element = document.querySelector(selector) if (!element) throw new Error(`Elemento da composição ausente: ${selector}`) return element.getBoundingClientRect() } const hero = bounds('[data-home-section="hero"]') const stage = bounds('.campaign-hero-stage') const portrait = bounds('.campaign-hero-portrait') const lula = bounds('.campaign-hero-ally--lula') const wagner = bounds('.campaign-hero-ally--wagner') const title = bounds('.campaign-hero-title') return { portrait: { left: portrait.left, width: portrait.width }, portraitBottomGap: hero.bottom - portrait.bottom, lulaBottomGap: hero.bottom - lula.bottom, wagnerBottomGap: hero.bottom - wagner.bottom, portraitTitleGap: title.left - portrait.right, stageWidth: stage.width, } }) } const short = await measureComposition(1340, 70) const regular = await measureComposition(1340, 700) expect(Math.abs(short.portrait.width - regular.portrait.width)).toBeLessThanOrEqual(1) expect(Math.abs(short.portrait.left - regular.portrait.left)).toBeLessThanOrEqual(1) for (const composition of [short, regular]) { expect(Math.abs(composition.portraitBottomGap)).toBeLessThanOrEqual(1) expect(Math.abs(composition.lulaBottomGap)).toBeLessThanOrEqual(1) expect(Math.abs(composition.wagnerBottomGap)).toBeLessThanOrEqual(1) } const ultraWide = await measureComposition(2358, 1030) expect(Math.abs(ultraWide.portraitBottomGap)).toBeLessThanOrEqual(1) expect(Math.abs(ultraWide.lulaBottomGap)).toBeLessThanOrEqual(1) expect(Math.abs(ultraWide.wagnerBottomGap)).toBeLessThanOrEqual(1) expect(ultraWide.portraitTitleGap).toBeLessThanOrEqual(64) expect(ultraWide.stageWidth).toBeLessThanOrEqual(1920) }) test('keeps the hero readable with WCAG text spacing overrides', async ({ page }) => { await page.goto('/') await expect(page).toHaveURL(/\/$/) await page.addStyleTag({ content: ` [data-home-section="hero"] :is(h1, p, a, small, span, strong) { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } [data-home-section="hero"] p { margin-bottom: 2em !important; } `, }) for (const viewport of [ { width: 320, height: 568 }, { width: 393, height: 852 }, { width: 768, height: 1024 }, { width: 1024, height: 597 }, { width: 1366, height: 768 }, { width: 1600, height: 600 }, { width: 1920, height: 840 }, ]) { await page.setViewportSize(viewport) const spacing = await page.evaluate(() => { const element = (selector: string) => { const match = document.querySelector(selector) if (!match) throw new Error(`Elemento ausente no teste de espaçamento: ${selector}`) return match } const bounds = (selector: string) => { return element(selector).getBoundingClientRect() } const hero = bounds('[data-home-section="hero"]') const title = bounds('.campaign-hero-title') const copy = bounds('.campaign-hero-copy') const actions = bounds('.campaign-hero-actions') const accolades = bounds('.campaign-hero-accolades') const secondaryAction = element('[data-cta="secondary"]') const primaryAction = element('[data-cta="primary"]') const scrollContainer = document.querySelector('[data-theme="campaign-site"]') if (!scrollContainer) throw new Error('Container da campanha ausente.') const overlapArea = (first: DOMRect, second: DOMRect) => Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left)) * Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top)) return { titleCopyGap: copy.top - title.bottom, copyActionsGap: actions.top - copy.bottom, actionsBottomGap: hero.bottom - actions.bottom, accoladesBottomGap: hero.bottom - accolades.bottom, actionsAccoladesOverlap: overlapArea(actions, accolades), primaryActionOverflow: primaryAction.scrollHeight - primaryAction.clientHeight, secondaryActionOverflow: secondaryAction.scrollHeight - secondaryAction.clientHeight, horizontalOverflow: scrollContainer.scrollWidth - scrollContainer.clientWidth, } }) expect(spacing.titleCopyGap).toBeGreaterThanOrEqual(0) expect(spacing.copyActionsGap).toBeGreaterThanOrEqual(0) expect(spacing.actionsBottomGap).toBeGreaterThanOrEqual(44) expect(spacing.accoladesBottomGap).toBeGreaterThanOrEqual(0) expect(spacing.actionsAccoladesOverlap).toBe(0) expect(spacing.primaryActionOverflow).toBeLessThanOrEqual(0) expect(spacing.secondaryActionOverflow).toBeLessThanOrEqual(0) expect(spacing.horizontalOverflow).toBeLessThanOrEqual(1) } }) test('shows the campaign message sections as static grids on desktop', async ({ page }) => { await page.goto('/') const problemGrid = page.locator('[data-home-section="problem"] [data-grid="problem"]') await expect(problemGrid).toBeVisible() await expect(problemGrid.getByRole('listitem')).toHaveCount(3) const problemBoxes = await problemGrid .getByRole('listitem') .evaluateAll((items) => items.map((item) => item.getBoundingClientRect())) expect(new Set(problemBoxes.map((box) => Math.round(box.y))).size).toBe(1) expect(new Set(problemBoxes.map((box) => Math.round(box.x))).size).toBe(3) expect(problemBoxes.every((box) => box.width > 0 && box.height > 0)).toBe(true) const flagsGrid = page.locator('[data-home-section="flags"] [data-grid="flags"]') await expect(flagsGrid).toBeVisible() await expect(flagsGrid.getByRole('listitem')).toHaveCount(6) const flagsBoxes = await flagsGrid .getByRole('listitem') .evaluateAll((items) => items.map((item) => item.getBoundingClientRect())) expect(new Set(flagsBoxes.map((box) => Math.round(box.x))).size).toBe(3) expect(new Set(flagsBoxes.map((box) => Math.round(box.y))).size).toBe(2) // No carousel chrome on desktop: regions and chips are display:none (out // of the a11y tree) and the hidden carousel never starts its auto-advance // chain, so no aria-current state exists anywhere in the grids. await expect( page.getByRole('region', { name: 'Bandeiras que tornam esta eleição decisiva' }), ).toHaveCount(0) await expect(page.getByRole('region', { name: 'Bandeiras da campanha' })).toHaveCount(0) await expect(page.locator('[data-carousel-chips]')).toBeHidden() await expect(problemGrid.locator('[aria-current]')).toHaveCount(0) await expect(flagsGrid.locator('[aria-current]')).toHaveCount(0) // The grids stay inside their sections with breathing room at every // desktop width, no card is clipped (no hidden scroll) and nothing // overflows the page horizontally. Fonts settle first: the flags cards // are content-height, so a fallback-font measurement could differ from // the real layout. await page.evaluate(() => document.fonts.ready) for (const width of [1024, 1280, 1440, 1920]) { await page.setViewportSize({ width, height: 900 }) const spacing = await page.evaluate(() => { const section = (selector: string) => { const element = document.querySelector(selector) if (!element) throw new Error(`Seção ausente: ${selector}`) return element.getBoundingClientRect() } const gridBottom = (selector: string) => { const grid = document.querySelector(selector) if (!grid) throw new Error(`Grade ausente: ${selector}`) const cards = Array.from(grid.querySelectorAll('li')) return Math.max(...cards.map((card) => card.getBoundingClientRect().bottom)) } const gridClipped = (selector: string) => { const grid = document.querySelector(selector) if (!grid) throw new Error(`Grade ausente: ${selector}`) return grid.scrollHeight - grid.clientHeight } const problem = section('[data-home-section="problem"]') const flags = section('[data-home-section="flags"]') const scrollContainer = document.querySelector('[data-theme="campaign-site"]') return { problemBottomGap: problem.bottom - gridBottom('[data-home-section="problem"] [data-grid]'), flagsBottomGap: flags.bottom - gridBottom('[data-home-section="flags"] [data-grid]'), problemClipped: gridClipped('[data-home-section="problem"] [data-grid]'), flagsClipped: gridClipped('[data-home-section="flags"] [data-grid]'), horizontalOverflow: scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0, } }) expect(spacing.problemBottomGap).toBeGreaterThanOrEqual(24) expect(spacing.flagsBottomGap).toBeGreaterThanOrEqual(24) expect(spacing.problemClipped).toBeLessThanOrEqual(1) expect(spacing.flagsClipped).toBeLessThanOrEqual(1) expect(spacing.horizontalOverflow).toBeLessThanOrEqual(1) } }) test('shows the story video section after the flags with the YouTube embed', async ({ page }) => { await page.goto('/') const story = page.locator('[data-home-section="story"]') await expect(story).toBeVisible() // Position: after the flags section, before the footer. const order = await page.evaluate(() => { const top = (selector: string) => { const element = document.querySelector(selector) if (!element) throw new Error(`Seção ausente: ${selector}`) return element.getBoundingClientRect().top } return { flagsTop: top('[data-home-section="flags"]'), storyTop: top('[data-home-section="story"]'), footerTop: top('footer'), } }) expect(order.storyTop).toBeGreaterThan(order.flagsTop) expect(order.footerTop).toBeGreaterThan(order.storyTop) // Draft copy (eyebrow + title). await expect(story.getByText('Nossa história')).toBeVisible() await expect( story.getByRole('heading', { name: 'Conheça a trajetória de Jorge Solla' }), ).toBeVisible() // The embed: youtube-nocookie (LGPD), native lazy, fullscreen, a11y title. const embed = story.locator('iframe') await expect(embed).toBeVisible() await expect(embed).toHaveAttribute( 'src', /youtube-nocookie\.com\/embed\/i_fbclWWC5o.*playsinline=1/, ) await expect(embed).toHaveAttribute('loading', 'lazy') await expect(embed).toHaveAttribute('allow', /autoplay/) await expect(embed).toHaveAttribute('allowfullscreen', '') await expect(embed).toHaveAttribute('title', /trajetória de Jorge Solla/) // The header CTA scrolls to the player. await story.getByRole('button', { name: 'Assistir à história' }).click() await expect(embed).toBeInViewport() // Desktop scene of the draft: copy column left, frame right, vertically // centered. await page.setViewportSize({ width: 1440, height: 900 }) const desktopLayout = await page.evaluate(() => { const storySection = document.querySelector('[data-home-section="story"]') if (!storySection) throw new Error('Seção ausente') const copy = storySection.querySelector('div.max-w-md') const frame = storySection.querySelector('iframe')?.parentElement if (!copy || !frame) throw new Error('Colunas ausentes') const copyBox = copy.getBoundingClientRect() const frameBox = frame.getBoundingClientRect() return { copyRight: Math.round(copyBox.right), frameLeft: Math.round(frameBox.left), centerDelta: Math.round( copyBox.top + copyBox.height / 2 - (frameBox.top + frameBox.height / 2), ), } }) expect(desktopLayout.copyRight).toBeLessThan(desktopLayout.frameLeft) expect(Math.abs(desktopLayout.centerDelta)).toBeLessThanOrEqual(2) // The frame keeps the 9:16 ratio on mobile without horizontal overflow. await page.setViewportSize({ width: 390, height: 844 }) const frame = await embed.boundingBox() expect(frame).not.toBeNull() expect(frame!.height / frame!.width).toBeCloseTo(16 / 9, 1) const overflow = await page.evaluate(() => { const scrollContainer = document.querySelector('[data-theme="campaign-site"]') return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0 }) expect(overflow).toBeLessThanOrEqual(1) }) test('shows the sound section between the story and the cards with the own radio player', async ({ page, }) => { const zenoRequests: string[] = [] page.on('request', (req) => { if (req.url().includes('zeno.fm')) zenoRequests.push(req.url()) }) await page.goto('/') const sound = page.locator('[data-home-section="sound"]') await expect(sound).toBeVisible() await expect(sound.getByText('A trilha da nossa caminhada')).toBeVisible() // Position: after the story, before the cards (the conversion block stays // last). The jingle grid is content-dependent, so this spec never counts // cards — counts belong to the serial frontendJingles spec. const tops = await page.evaluate(() => { const top = (selector: string) => { const element = document.querySelector(selector) if (!element) throw new Error(`Seção ausente: ${selector}`) return element.getBoundingClientRect().top } return { story: top('[data-home-section="story"]'), sound: top('[data-home-section="sound"]'), cards: top('[data-home-section="cards"]'), } }) expect(tops.sound).toBeGreaterThan(tops.story) expect(tops.cards).toBeGreaterThan(tops.sound) // S25 — the own player replaces the S24 iframe: one lazy `