import type { APIRequestContext, Locator, Page } from '@playwright/test' import { readFileSync } from 'node:fs' import { adminHeaders } from '../helpers/adminApi' 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: [] }) } 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('keeps carousel auto-advance and chips on mobile', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/') await expect(page.locator('[data-home-section="problem"] [data-grid]')).toBeHidden() await expect(page.locator('[data-home-section="flags"] [data-grid]')).toBeHidden() const problemCarousel = page.getByRole('region', { name: 'Bandeiras que tornam esta eleição decisiva', }) const firstProblem = problemCarousel.getByRole('listitem', { name: '1 de 3' }) const secondProblem = problemCarousel.getByRole('listitem', { name: '2 de 3' }) await expect(firstProblem).toHaveAttribute('aria-current', 'true') await expect(secondProblem).toHaveAttribute('aria-current', 'true', { timeout: 6_000 }) const flagCarousel = page.getByRole('region', { name: 'Bandeiras da campanha' }) const educationChip = flagCarousel.getByRole('button', { name: 'Educação' }) await educationChip.click() await expect(educationChip).toHaveAttribute('aria-current', 'true') }) test('supports a real horizontal touch swipe on mobile', async ({ page }) => { await page.setViewportSize({ width: 430, height: 900 }) await page.goto('/') const overlayBox = await page.locator('[data-mobile-hero-overlay]').boundingBox() expect(overlayBox?.x).toBe(0) expect(overlayBox?.width).toBe(430) const mobileSpacing = await page.evaluate(() => { const rect = (selector: string) => document.querySelector(selector)?.getBoundingClientRect() const problemSection = rect('[data-home-section="problem"]') const problemLead = rect('#problem-title + p') const problemCarousel = rect('[data-home-section="problem"] [data-carousel]') const flagsSection = rect('[data-home-section="flags"]') const flagsCarousel = rect('[data-home-section="flags"] [data-carousel]') return { leadGap: problemLead && problemCarousel ? problemCarousel.top - problemLead.bottom : undefined, problemBottomGap: problemSection && problemCarousel ? problemSection.bottom - problemCarousel.bottom : undefined, flagsBottomGap: flagsSection && flagsCarousel ? flagsSection.bottom - flagsCarousel.bottom : undefined, } }) expect(mobileSpacing.leadGap).toBeLessThan(40) expect(mobileSpacing.problemBottomGap).toBeGreaterThanOrEqual(40) expect(mobileSpacing.flagsBottomGap).toBeGreaterThanOrEqual(40) const carousel = page.getByRole('region', { name: 'Bandeiras da campanha' }) await carousel.focus() const track = carousel.locator('[data-carousel-track]') await track.scrollIntoViewIfNeeded() const box = await track.boundingBox() if (!box) throw new Error('O trilho das bandeiras não ficou visível para o swipe.') await swipeLeft(page, box) await expect(carousel.getByRole('button', { name: 'Educação' })).toHaveAttribute( 'aria-current', 'true', ) }) }) /** * S1 — content section on the campaign home. Runs serially: the empty-state * test depends on the suite's default DB (no posts) and must execute before * the full-state test creates posts. Posts/tags are created through the * deployed REST API so the server process runs the real `afterChange` cache * hooks (a Local API call from the runner would throw on `revalidateTag`), * and cleanup busts the `posts` tag through the revalidate endpoint. */ test.describe('Campaign home content section', () => { test.describe.configure({ mode: 'serial' }) const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000' const revalidateSecret = process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret' const runSuffix = Date.now() const categoryName = `E2e Conteúdos ${runSuffix}` const categorySlug = `e2e-conteudos-${runSuffix}` const hiddenTagName = `E2e Oculta ${runSuffix}` const hiddenTagSlug = `e2e-oculta-${runSuffix}` const createdPosts: number[] = [] const createdTags: number[] = [] test.beforeAll(async ({ request }) => { await seedTestUser() // Start every run with the social feed unconfigured so the empty-state // test stays deterministic even when a previous S2 run left settings or a // persisted snapshot behind (same reasoning as the `posts` bust below). const headers = await adminHeaders(request, baseURL) await resetSocialFeedSettings(request, headers) await bustSocialFeed(request) }) const createTag = async ( request: APIRequestContext, headers: Record, name: string, slug: string, hidden = false, ) => { const response = await request.post(`${baseURL}/api/tag`, { headers, data: { name, slug, hidden }, }) expect(response.ok()).toBeTruthy() const { doc } = await response.json() createdTags.push(doc.id) return doc } const createPost = async ( request: APIRequestContext, headers: Record, data: { title: string; slug: string; type: string; category: number; publishedDate: string }, ) => { const response = await request.post(`${baseURL}/api/post`, { headers, data: { ...data, _status: 'published' }, }) expect(response.ok()).toBeTruthy() const { doc } = await response.json() createdPosts.push(doc.id) return doc } // ISR serves the stale page while it regenerates after a revalidateTag // (slower under the parallel suite), and a navigation that lands on it never // refreshes its DOM. Poll the server HTML positively until it converges to // the expected substring state (section presence or actual content, e.g. // video titles); the navigation that follows then always lands on the fresh // page. 12 attempts preserves the S1 convergence budget. const waitForHomeHTML = async ( request: APIRequestContext, includes: string[], excludes: string[] = [], attempts = 12, ) => { for (let attempt = 0; attempt < attempts; attempt += 1) { const response = await request.get(`${baseURL}/`).catch(() => undefined) if (response?.ok()) { const html = await response.text() const allIncluded = includes.every((needle) => html.includes(needle)) const anyExcluded = excludes.some((needle) => html.includes(needle)) if (allIncluded && !anyExcluded) return } await new Promise((resolve) => setTimeout(resolve, 1_000)) } throw new Error( `A home não convergiu após ${attempts}s (includes: ${includes.join(', ')}, excludes: ${excludes.join(', ')}).`, ) } const waitForHomeSectionState = async ( request: APIRequestContext, expected: 'present' | 'absent', attempts = 12, ) => { if (expected === 'present') { await waitForHomeHTML(request, ['data-home-section="contents"'], [], attempts) } else { await waitForHomeHTML(request, [], ['data-home-section="contents"'], attempts) } } /** * Polls the RENDERED contents section for a card/link count, scoped to * `[data-home-section="contents"]`. The server-HTML polls above converge on * the ISR response, but the page may serve a heuristic-cached body that lags * the server (measured in the OPS83 verify run #15: the server showed the * kill switch applied while the rendered section still held the Instagram * link). This is the source that observes the actual payload — pair it with * `gotoHomeFresh` so the navigation itself misses the cache. */ const expectSectionCount = (locator: Locator, expected: number, timeout = 15_000) => expect .poll(async () => locator.count(), { timeout, message: 'contents section card count' }) .toBe(expected) // The home route is ISR (s-maxage 300, no max-age): Chromium applies a // heuristic cache to such responses and a plain goto may serve the previous // body even after the poll converged (measured on the S2 snapshot tests — // flaky in CI, deterministic in prod-mode local). Navigating with a unique // query param misses every cache entry (HTTP + Next router), so the page // always comes fresh from the server; the app ignores unknown params. const gotoHomeFresh = async (page: Page) => { await page.goto(`/?e2e=${Date.now()}`) } // ---- S2/S3 — YouTube + Instagram feed: stub + settings helpers ---- // Same derivation as playwright.config.ts: the stubs listen on dev port + // 1000 (YouTube) and + 2000 (Instagram). const youtubeStubUrl = youtubeStubUrlFor(baseURL) const instagramStubUrl = instagramStubUrlFor(baseURL) const setYouTubeStubState = async (request: APIRequestContext, state: 'ok' | 'fail') => { const response = await request.post(`${youtubeStubUrl}/__stub/state`, { data: { state } }) expect(response.ok()).toBeTruthy() } const setInstagramStubState = async (request: APIRequestContext, state: 'ok' | 'fail') => { const response = await request.post(`${instagramStubUrl}/__stub/state`, { data: { state } }) expect(response.ok()).toBeTruthy() } const updateSocialFeedSettings = async ( request: APIRequestContext, headers: Record, data: Record, ) => { const response = await request.post(`${baseURL}/api/globals/social-feed-settings`, { headers, data, }) expect(response.ok()).toBeTruthy() } // Full unconfigured state: both feeds return null and the S1 behavior is intact. const resetSocialFeedSettings = async ( request: APIRequestContext, headers: Record, ) => { await updateSocialFeedSettings(request, headers, { enabled: true, youtubeEnabled: true, youtubeApiKey: '', youtubeChannelId: '', youtubeMaxItems: 3, instagramEnabled: true, instagramAccessToken: '', instagramUserId: '', instagramMaxItems: 3, excludedItems: [], youtubeFeedSnapshot: null, instagramFeedSnapshot: null, instagramSyncStatus: null, }) } const bustSocialFeed = async (request: APIRequestContext) => request .post(`${baseURL}/api/revalidate?tag=social-feed`, { headers: { 'x-revalidate-secret': revalidateSecret }, }) .catch(() => undefined) // Best-effort S2 teardown: deletes the created posts/tags, resets the social // feed to the unconfigured state and converges the home back to the empty // section so the serial chain starts deterministic on the next run. const cleanupS2Fixtures = async (request: APIRequestContext, headers: Record) => { for (const id of createdPosts) { await request.delete(`${baseURL}/api/post/${id}`, { headers }).catch(() => undefined) } for (const id of createdTags) { await request.delete(`${baseURL}/api/tag/${id}`, { headers }).catch(() => undefined) } await resetSocialFeedSettings(request, headers).catch(() => undefined) await bustSocialFeed(request) await request .post(`${baseURL}/api/revalidate?tag=posts`, { headers: { 'x-revalidate-secret': revalidateSecret }, }) .catch(() => undefined) await waitForHomeSectionState(request, 'absent').catch(() => undefined) } test('hides the content section while no articles are visible', async ({ page, request }) => { // The dev server persists its `posts` cache to .next-e2e/cache/fetch-cache // between runs, so an earlier full-state run could leave it populated. // Busting first makes the empty state deterministic on every rerun. // OPS83: assert the revalidate actually landed (a swallowed failure made // the server poll below time out in verify run #15) and converge on the // rendered DOM with an explicit ISR-regeneration budget — never a blind // wall-clock bump. const revalidate = await request.post(`${baseURL}/api/revalidate?tag=posts`, { headers: { 'x-revalidate-secret': revalidateSecret }, }) expect(revalidate).toBeOK() await waitForHomeSectionState(request, 'absent', 30) // Plain URL on purpose: the test also pins the canonical home URL. await page.goto('/') await expect(page).toHaveURL(/\/$/) await expectSectionCount(page.locator('[data-home-section="contents"]'), 0) await expect(page.getByText('A caminhada, em tempo real')).toHaveCount(0) const proof = await page.locator('[data-home-section="proof"]').boundingBox() const problem = await page.locator('[data-home-section="problem"]').boundingBox() expect(proof).not.toBeNull() expect(problem).not.toBeNull() expect(problem!.y - (proof!.y + proof!.height)).toBeLessThanOrEqual(1) }) test('renders the article bento and the one-per-screen mobile carousel', async ({ page, request, }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) const visibleTag = await createTag(request, headers, categoryName, categorySlug) const hiddenTag = await createTag(request, headers, hiddenTagName, hiddenTagSlug, true) const now = Date.now() const posts = [ { title: 'E2e Artigo em destaque', slug: `e2e-artigo-destaque-${runSuffix}`, type: 'artigo', minutesAgo: 1, }, { title: 'E2e Notícia recente', slug: `e2e-noticia-recente-${runSuffix}`, type: 'noticia', minutesAgo: 60 * 26, }, { title: 'E2e Campanha em ação', slug: `e2e-campanha-${runSuffix}`, type: 'campanha', minutesAgo: 60 * 50, }, { title: 'E2e Notícia do interior', slug: `e2e-noticia-interior-${runSuffix}`, type: 'noticia', minutesAgo: 60 * 74, }, { title: 'E2e Evento da agenda', slug: `e2e-evento-${runSuffix}`, type: 'evento', minutesAgo: 60 * 98, }, { title: 'E2e Conteúdo oculto', slug: `e2e-oculto-${runSuffix}`, type: 'noticia', minutesAgo: 60 * 122, }, ] for (const post of posts) { const isHidden = post.slug.includes('oculto') await createPost(request, headers, { title: post.title, slug: post.slug, type: post.type, category: isHidden ? hiddenTag.id : visibleTag.id, publishedDate: new Date(now - post.minutesAgo * 60_000).toISOString(), }) } try { await page.setViewportSize({ width: 1280, height: 900 }) // The create hooks revalidated the `posts` tag in the server process; // wait until the regenerated page actually shows the section. await waitForHomeSectionState(request, 'present') await gotoHomeFresh(page) const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() await expect( section.getByRole('heading', { name: 'A caminhada, em tempo real' }), ).toBeVisible() await expect(section.getByText('Acompanhe de perto')).toBeVisible() await expect( section.getByText( 'Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.', ), ).toBeVisible() await expect(section.getByRole('link', { name: /Ver artigos/ })).toHaveAttribute( 'href', '/artigos', ) const bentoCards = section.locator( 'a[href^="/artigo/"], a[href^="/noticia/"], a[href^="/campanha/"], a[href^="/evento/"]', ) await expect(bentoCards.filter({ visible: true })).toHaveCount(5) // S7 — cards are badge-free: no type chip (site or external source) // anywhere in the section. for (const badge of ['Artigo', 'Notícia', 'Campanha', 'Evento', 'YouTube', 'Instagram']) { await expect(section.getByText(badge, { exact: true })).toHaveCount(0) } await expect(section.getByText('E2e Artigo em destaque').first()).toBeVisible() await expect(section.getByText('E2e Conteúdo oculto')).toHaveCount(0) await expect(section.getByText('E2e Oculta', { exact: false })).toHaveCount(0) const featured = section.getByRole('link', { name: /E2e Artigo em destaque/ }) const featuredHref = await featured.getAttribute('href') expect(featuredHref).toBe(`/artigo/${categorySlug}/e2e-artigo-destaque-${runSuffix}`) const firstCard = bentoCards.first() await expect(firstCard).toBeVisible() await expect(firstCard.locator('h3')).toHaveText('E2e Artigo em destaque') await expect(firstCard.locator('span').last()).toContainText(categoryName) // S5 — the section must read as a zone of its own: a banded background // with a top hairline against the white proof strip, and white cards // that pop against the band. Exact computed values pin the tokens // (`--campaign-band` #ebe9e9, `--campaign-line` rgb(0 0 0 / 12%)) so a // broken or removed token cannot silently pass. await expect(section).toHaveCSS('background-color', 'rgb(235, 233, 233)') await expect(section).toHaveCSS('border-top-width', '1px') await expect(section).toHaveCSS('border-top-color', 'rgba(0, 0, 0, 0.12)') await expect(firstCard).toHaveCSS('background-color', 'rgb(255, 255, 255)') // S7 — flat editorial cards: no radius, border or shadow (the white card // reads against the band on shape alone), cover edge to edge. await expect(firstCard).toHaveCSS('border-radius', '0px') await expect(firstCard).toHaveCSS('border-width', '0px') await expect(firstCard).toHaveCSS('box-shadow', 'none') await expect(firstCard.locator('.aspect-video')).toHaveCSS('border-radius', '0px') // Edge to edge: the cover spans the full card width (no container padding). const cardBox = await firstCard.boundingBox() const coverBox = await firstCard.locator('.aspect-video').boundingBox() expect(coverBox).not.toBeNull() expect(cardBox).not.toBeNull() expect(Math.abs(coverBox!.width - cardBox!.width)).toBeLessThanOrEqual(1) await featured.click() await expect(page).toHaveURL(new RegExp(`${featuredHref}$`)) await page.goBack() await page.setViewportSize({ width: 390, height: 844 }) await gotoHomeFresh(page) const carousel = page.getByRole('region', { name: 'Conteúdos recentes' }) await expect(carousel).toBeVisible() await expect(carousel.getByText('E2e Conteúdo oculto')).toHaveCount(0) const track = carousel.locator('[data-carousel-track]') await track.scrollIntoViewIfNeeded() const trackBox = await track.boundingBox() if (!trackBox) throw new Error('O trilho do carrossel de conteúdo não ficou visível.') const slides = await page.evaluate(() => Array.from( document.querySelectorAll('[data-carousel="contents"] [data-carousel-index]'), ).map((slide) => slide.getBoundingClientRect().width), ) expect(slides).toHaveLength(5) expect(slides.every((width) => Math.abs(width - slides[0]) < 1)).toBe(true) await swipeLeft(page, trackBox) await expect(carousel.getByText('2 de 5 · deslize para ver os próximos')).toBeVisible() await carousel.getByRole('button', { name: 'Ir para o conteúdo 4 de 5' }).click() await expect(carousel.getByText('4 de 5 · deslize para ver os próximos')).toBeVisible() // Leave the home before deleting: prod prefetches the card links, and a // prefetch answered after the deletion would 404 (guard noise). Leaving // the page aborts in-flight prefetches and stops new ones. await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { const headers2 = await adminHeaders(request, baseURL).catch(() => undefined) if (headers2) { for (const id of createdPosts) { await request .delete(`${baseURL}/api/post/${id}`, { headers: headers2 }) .catch(() => undefined) } for (const id of createdTags) { await request .delete(`${baseURL}/api/tag/${id}`, { headers: headers2 }) .catch(() => undefined) } await request .post(`${baseURL}/api/revalidate?tag=posts`, { headers: { 'x-revalidate-secret': revalidateSecret }, }) .catch(() => undefined) // Converge the persisted dev cache back to the empty state so the // next run starts deterministic (fail-closed on delete, too). await waitForHomeSectionState(request, 'absent') await page.setViewportSize({ width: 1280, height: 900 }) await gotoHomeFresh(page) await expect(page.locator('[data-home-section="contents"]')).toHaveCount(0) } } }) test('renders the YouTube feed with exclusions and opens videos on the platform', async ({ page, request, }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'ok') const mixTag = await createTag(request, headers, `E2e Mix ${runSuffix}`, `e2e-mix-${runSuffix}`) await createPost(request, headers, { title: 'E2e Artigo no mix', slug: `e2e-artigo-mix-${runSuffix}`, type: 'artigo', category: mixTag.id, publishedDate: new Date(Date.now() - 60 * 60_000).toISOString(), }) // The newest stub video is excluded: the board must skip it and promote the // next eligible one to the featured slot (the acceptance's "pula para o // próximo elegível"). await updateSocialFeedSettings(request, headers, { enabled: true, youtubeEnabled: true, youtubeApiKey: 'e2e-youtube-api-key', youtubeChannelId: 'UCe2eTestChannel', youtubeMaxItems: 3, excludedItems: [{ platform: 'youtube', itemId: 'e2evideo004' }], youtubeFeedSnapshot: null, }) try { await waitForHomeHTML( request, ['E2e Vídeo em destaque', 'E2e Artigo no mix'], ['E2e Vídeo excluído'], ) await page.setViewportSize({ width: 1280, height: 900 }) await gotoHomeFresh(page) const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() const featured = section.getByRole('link', { name: /E2e Vídeo em destaque/ }) await expect(featured).toBeVisible() await expect(featured).toHaveAttribute('href', 'https://www.youtube.com/watch?v=e2evideo001') await expect(featured).toHaveAttribute('target', '_blank') await expect(featured).toHaveAttribute('rel', 'noopener noreferrer') await expect( section.getByText('há 30 minutos · 12,4 mil visualizações').first(), ).toBeVisible() // S7 — no external-source chip on YouTube cards anymore. await expect(section.getByText('YouTube', { exact: true })).toHaveCount(0) await expect(section.getByText('E2e Vídeo excluído')).toHaveCount(0) await expect(section.getByRole('link', { name: /E2e Artigo no mix/ }).first()).toBeVisible() await expect(section.getByText('há 2 horas · 8,1 mil visualizações').first()).toBeVisible() await expect(section.getByText('987 visualizações').first()).toBeVisible() const channelLink = section.getByRole('link', { name: /YouTube →/ }) await expect(channelLink).toHaveAttribute( 'href', 'https://www.youtube.com/channel/UCe2eTestChannel', ) await expect(channelLink).toHaveAttribute('target', '_blank') await page.setViewportSize({ width: 390, height: 844 }) await gotoHomeFresh(page) const carousel = page.getByRole('region', { name: 'Conteúdos recentes' }) await expect(carousel).toBeVisible() await expect(carousel.getByText('1 de 4 · deslize para ver os próximos')).toBeVisible() await expect(carousel.getByText('E2e Vídeo excluído')).toHaveCount(0) await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { await setYouTubeStubState(request, 'ok') await cleanupS2Fixtures(request, headers) } }) test('hides YouTube cards and keeps articles when the API fails without a snapshot', async ({ page, request, }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'fail') const fallbackTag = await createTag( request, headers, `E2e Fallback ${runSuffix}`, `e2e-fallback-${runSuffix}`, ) await createPost(request, headers, { title: 'E2e Artigo fallback', slug: `e2e-artigo-fallback-${runSuffix}`, type: 'noticia', category: fallbackTag.id, publishedDate: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), }) await updateSocialFeedSettings(request, headers, { enabled: true, youtubeEnabled: true, youtubeApiKey: 'e2e-youtube-api-key', youtubeChannelId: 'UCe2eTestChannel', youtubeMaxItems: 3, excludedItems: [], youtubeFeedSnapshot: null, }) try { // The feed is configured, so the "YouTube →" link stays; without a // snapshot the cards are silently absent and the article carries the // section (page never breaks, no error shown). await waitForHomeHTML( request, ['E2e Artigo fallback', 'YouTube →'], ['E2e Vídeo em destaque'], ) await gotoHomeFresh(page) const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() await expect(section.getByRole('link', { name: /E2e Artigo fallback/ })).toBeVisible() await expect(section.getByText('E2e Vídeo em destaque')).toHaveCount(0) await expect(section.getByText('E2e Vídeo de caravana')).toHaveCount(0) await expect(section.getByRole('link', { name: /YouTube →/ })).toBeVisible() await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { await setYouTubeStubState(request, 'ok') await cleanupS2Fixtures(request, headers) } }) test('keeps the last snapshot while the API is down', async ({ page, request }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'ok') const baseSettings = { enabled: true, youtubeEnabled: true, youtubeApiKey: 'e2e-youtube-api-key', youtubeChannelId: 'UCe2eTestChannel', youtubeMaxItems: 3, excludedItems: [{ platform: 'youtube', itemId: 'e2evideo004' }], youtubeFeedSnapshot: null, } await updateSocialFeedSettings(request, headers, baseSettings) try { // Live fetch succeeds and persists the raw snapshot. await waitForHomeHTML(request, ['E2e Vídeo em destaque'], ['E2e Vídeo excluído']) await gotoHomeFresh(page) await expect(page.getByRole('link', { name: /E2e Vídeo em destaque/ }).first()).toBeVisible() await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) // API down + a settings change: the re-execution must serve the snapshot // re-filtered by the CURRENT exclusions/maxItems (raw snapshot, applied // on read) — maxItems 2 proves the re-run read the new settings. The // snapshot field is OMITTED on purpose: an update merges over the // existing doc (Payload update semantics), so the persisted snapshot // survives the settings edit — exactly the production admin path. await setYouTubeStubState(request, 'fail') const { youtubeFeedSnapshot: _ignored, ...settingsWithoutSnapshot } = baseSettings await updateSocialFeedSettings(request, headers, { ...settingsWithoutSnapshot, youtubeMaxItems: 2, }) await waitForHomeHTML( request, ['E2e Vídeo em destaque', 'E2e Vídeo de caravana'], ['E2e Vídeo de entrevista'], ) await gotoHomeFresh(page) await expect(page.getByRole('link', { name: /E2e Vídeo em destaque/ }).first()).toBeVisible() await expect(page.getByRole('link', { name: /E2e Vídeo de caravana/ })).toBeVisible() await expect(page.getByText('E2e Vídeo de entrevista')).toHaveCount(0) await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) // API back: the live feed returns with the original cap. await setYouTubeStubState(request, 'ok') await updateSocialFeedSettings(request, headers, baseSettings) await waitForHomeHTML(request, ['E2e Vídeo de entrevista'], ['E2e Vídeo excluído']) await gotoHomeFresh(page) await expect(page.getByText('E2e Vídeo de entrevista').first()).toBeVisible() await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { await setYouTubeStubState(request, 'ok') await cleanupS2Fixtures(request, headers) } }) test('renders the Instagram feed with exclusions and opens posts on the platform', async ({ page, request, }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') const mixTag = await createTag( request, headers, `E2e Mix IG ${runSuffix}`, `e2e-mix-ig-${runSuffix}`, ) await createPost(request, headers, { title: 'E2e Artigo no mix IG', slug: `e2e-artigo-mix-ig-${runSuffix}`, type: 'artigo', category: mixTag.id, publishedDate: new Date(Date.now() - 60 * 60_000).toISOString(), }) // The newest stub post is a grid mosaic marked as excluded: the board must // skip it and promote the next eligible one to the featured slot. await updateSocialFeedSettings(request, headers, { enabled: true, youtubeEnabled: true, youtubeApiKey: '', youtubeChannelId: '', youtubeMaxItems: 3, instagramEnabled: true, instagramAccessToken: 'e2e-ig-token', instagramUserId: '17841400000000000', instagramMaxItems: 3, excludedItems: [{ platform: 'instagram', itemId: 'e2e-ig-grade-excluido-5' }], youtubeFeedSnapshot: null, instagramFeedSnapshot: null, }) try { await waitForHomeHTML( request, ['E2e Post do muro', 'E2e Artigo no mix IG'], ['E2e Post de grade', 'YouTube →'], ) await page.setViewportSize({ width: 1280, height: 900 }) await gotoHomeFresh(page) const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() const featured = section.getByRole('link', { name: /E2e Post do muro/ }) await expect(featured).toBeVisible() await expect(featured).toHaveAttribute('href', 'https://www.instagram.com/p/e2e-ig-muro-1/') await expect(featured).toHaveAttribute('target', '_blank') await expect(featured).toHaveAttribute('rel', 'noopener noreferrer') // S7 — no external-source chip on Instagram cards anymore. await expect(section.getByText('Instagram', { exact: true })).toHaveCount(0) await expect(section.getByText('há 20 minutos').first()).toBeVisible() await expect(section.getByText('E2e Post de grade')).toHaveCount(0) // The captionless grid post shows the fallback title (it enters the // board automatically and the assessoria excludes it item by item). await expect(section.getByText('Publicação no Instagram').first()).toBeVisible() // Instagram cards render the 1:1 cover of the approved draft. await expect(featured.locator('.aspect-square').first()).toBeVisible() // Long captions are clamped with an ellipsis so a single post cannot // stretch the bento row (production captions run into hundreds of // characters; the stub's reel caption is deliberately long). const longCaptionTitle = section .getByRole('link', { name: /E2e Reel da caravana/ }) .locator('h3') await expect(longCaptionTitle).toHaveCSS('-webkit-line-clamp', '3') await expect(longCaptionTitle).toHaveCSS('overflow', 'hidden') await expect( section.getByRole('link', { name: /E2e Artigo no mix IG/ }).first(), ).toBeVisible() await expect(section.getByText('E2e Reel da caravana').first()).toBeVisible() const profileLink = section.getByRole('link', { name: /Seguir no Instagram/ }) await expect(profileLink).toHaveAttribute('href', 'https://www.instagram.com/depjorgesolla') await expect(profileLink).toHaveAttribute('target', '_blank') await page.setViewportSize({ width: 390, height: 844 }) await gotoHomeFresh(page) const carousel = page.getByRole('region', { name: 'Conteúdos recentes' }) await expect(carousel).toBeVisible() await expect(carousel.getByText('1 de 4 · deslize para ver os próximos')).toBeVisible() await expect(carousel.getByText('E2e Post de grade')).toHaveCount(0) await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { await setInstagramStubState(request, 'ok') await cleanupS2Fixtures(request, headers) } }) test('hides Instagram cards and keeps articles when the API fails without a snapshot', async ({ page, request, }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) await setInstagramStubState(request, 'fail') const fallbackTag = await createTag( request, headers, `E2e Fallback IG ${runSuffix}`, `e2e-fallback-ig-${runSuffix}`, ) await createPost(request, headers, { title: 'E2e Artigo fallback IG', slug: `e2e-artigo-fallback-ig-${runSuffix}`, type: 'noticia', category: fallbackTag.id, publishedDate: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), }) await updateSocialFeedSettings(request, headers, { enabled: true, youtubeEnabled: true, youtubeApiKey: '', youtubeChannelId: '', youtubeMaxItems: 3, instagramEnabled: true, instagramAccessToken: 'e2e-ig-token', instagramUserId: '17841400000000000', instagramMaxItems: 3, excludedItems: [], youtubeFeedSnapshot: null, instagramFeedSnapshot: null, }) try { // Without a snapshot there is no username to build the profile link // from, so the IG cards AND the "Seguir no Instagram →" link stay // silently absent and the article carries the section (page never // breaks, no error shown). await waitForHomeHTML( request, ['E2e Artigo fallback IG'], ['E2e Post do muro', 'Seguir no Instagram'], ) await gotoHomeFresh(page) const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() await expect(section.getByRole('link', { name: /E2e Artigo fallback IG/ })).toBeVisible() // Same rendered-DOM class as the kill-switch test (OPS83 run #15): the // server poll converged but the page served a stale body — poll the DOM. await expectSectionCount(section.getByText('E2e Post do muro'), 0) await expectSectionCount(section.getByRole('link', { name: /Seguir no Instagram/ }), 0) await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { await setInstagramStubState(request, 'ok') await cleanupS2Fixtures(request, headers) } }) test('keeps the last Instagram snapshot while the API is down', async ({ page, request }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) await setInstagramStubState(request, 'ok') const baseSettings = { enabled: true, youtubeEnabled: true, youtubeApiKey: '', youtubeChannelId: '', youtubeMaxItems: 3, instagramEnabled: true, instagramAccessToken: 'e2e-ig-token', instagramUserId: '17841400000000000', instagramMaxItems: 3, excludedItems: [{ platform: 'instagram', itemId: 'e2e-ig-grade-excluido-5' }], youtubeFeedSnapshot: null, instagramFeedSnapshot: null, } await updateSocialFeedSettings(request, headers, baseSettings) try { // Live fetch succeeds and persists the raw snapshot (with the username). await waitForHomeHTML(request, ['E2e Post do muro'], ['E2e Post de grade']) await gotoHomeFresh(page) await expect(page.getByRole('link', { name: /E2e Post do muro/ }).first()).toBeVisible() await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) // API down + a settings change: the re-execution must serve the snapshot // re-filtered by the CURRENT exclusions/maxItems (raw snapshot, applied // on read) — maxItems 2 proves the re-run read the new settings, and the // profile link survives because the username came from the snapshot. The // snapshot field is OMITTED on purpose: an update merges over the // existing doc (Payload update semantics), so the persisted snapshot // survives the settings edit — exactly the production admin path. await setInstagramStubState(request, 'fail') const { instagramFeedSnapshot: _ignored, ...settingsWithoutSnapshot } = baseSettings await updateSocialFeedSettings(request, headers, { ...settingsWithoutSnapshot, instagramMaxItems: 2, }) await waitForHomeHTML( request, ['E2e Post do muro', 'E2e Reel da caravana', 'Seguir no Instagram'], ['Publicação no Instagram'], ) await gotoHomeFresh(page) await expect(page.getByRole('link', { name: /E2e Post do muro/ }).first()).toBeVisible() await expect(page.getByText('E2e Reel da caravana').first()).toBeVisible() await expect(page.getByText('Publicação no Instagram')).toHaveCount(0) await expect(page.getByRole('link', { name: /Seguir no Instagram/ })).toBeVisible() await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) // API back: the live feed returns with the original cap. await setInstagramStubState(request, 'ok') await updateSocialFeedSettings(request, headers, baseSettings) await waitForHomeHTML(request, ['Publicação no Instagram'], ['E2e Post de grade']) await gotoHomeFresh(page) await expect(page.getByText('Publicação no Instagram').first()).toBeVisible() await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { await setInstagramStubState(request, 'ok') await cleanupS2Fixtures(request, headers) } }) test('the kill switch pauses the external feed without touching articles', async ({ page, request, }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') const killTag = await createTag( request, headers, `E2e Kill ${runSuffix}`, `e2e-kill-${runSuffix}`, ) await createPost(request, headers, { title: 'E2e Artigo kill switch', slug: `e2e-artigo-kill-${runSuffix}`, type: 'noticia', category: killTag.id, publishedDate: new Date(Date.now() - 3 * 60 * 60_000).toISOString(), }) await updateSocialFeedSettings(request, headers, { enabled: false, youtubeEnabled: true, youtubeApiKey: 'e2e-youtube-api-key', youtubeChannelId: 'UCe2eTestChannel', youtubeMaxItems: 3, instagramEnabled: true, instagramAccessToken: 'e2e-ig-token', instagramUserId: '17841400000000000', instagramMaxItems: 3, excludedItems: [], youtubeFeedSnapshot: null, instagramFeedSnapshot: null, }) try { // `enabled: false` pauses the whole external board: no cards AND no // platform links (YouTube and Instagram alike), while the article // section lives on. await waitForHomeHTML( request, ['E2e Artigo kill switch'], ['E2e Vídeo em destaque', 'YouTube →', 'E2e Post do muro', 'Seguir no Instagram'], ) // The page can serve a heuristic-cached body that lags the server poll // (OPS83 run #15): navigate fresh AND poll the rendered section DOM — // the IG link is what failed there, scoped to the same payload. await gotoHomeFresh(page) const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() await expect(section.getByRole('link', { name: /E2e Artigo kill switch/ })).toBeVisible() await expectSectionCount(section.getByText('E2e Vídeo em destaque'), 0) await expectSectionCount(section.getByRole('link', { name: /YouTube →/ }), 0) await expectSectionCount(section.getByText('E2e Post do muro'), 0) await expectSectionCount(section.getByRole('link', { name: /Seguir no Instagram/ }), 0) await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') await cleanupS2Fixtures(request, headers) } }) test('shares every content card on WhatsApp and copies the link (S4)', async ({ page, request, context, }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') const shareTagSlug = `e2e-share-${runSuffix}` const shareTag = await createTag(request, headers, `E2e Share ${runSuffix}`, shareTagSlug) const articleTitle = 'E2e Artigo do compartilhar' await createPost(request, headers, { title: articleTitle, slug: `e2e-artigo-compartilhar-${runSuffix}`, type: 'noticia', category: shareTag.id, publishedDate: new Date(Date.now() - 5 * 60_000).toISOString(), }) // Both feeds on, no exclusions: the board holds one card of each source // (newest article featured + Instagram/YouTube from the stubs). await updateSocialFeedSettings(request, headers, { enabled: true, youtubeEnabled: true, youtubeApiKey: 'e2e-youtube-api-key', youtubeChannelId: 'UCe2eTestChannel', youtubeMaxItems: 3, instagramEnabled: true, instagramAccessToken: 'e2e-ig-token', instagramUserId: '17841400000000000', instagramMaxItems: 3, excludedItems: [], youtubeFeedSnapshot: null, instagramFeedSnapshot: null, }) await context.grantPermissions(['clipboard-read', 'clipboard-write']) try { await waitForHomeHTML(request, [articleTitle, 'E2e Vídeo em destaque', 'E2e Post do muro']) await page.setViewportSize({ width: 1280, height: 900 }) await gotoHomeFresh(page) const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() // Every card of the 1+4 bento (CONTENT_SECTION_LIMIT = 5) carries // exactly one discreet share button. await expect(section.getByRole('button', { name: 'Compartilhar' })).toHaveCount(5) const urlBeforeShare = page.url() const waMessage = async (item: Locator) => { const href = await item.getAttribute('href') expect(href).toBeTruthy() return decodeURIComponent(href!.slice('https://wa.me/?text='.length)).replace(/\+/g, ' ') } // Article card (featured): the popover builds the per-source message with // the canonical absolute article link, opened in a new tab with noopener. const articleCard = section.getByRole('link', { name: /E2e Artigo do compartilhar/ }) const articleShare = articleCard .locator('xpath=..') .getByRole('button', { name: 'Compartilhar' }) await articleShare.click() await expect(page).toHaveURL(urlBeforeShare) const waItem = page.getByRole('link', { name: 'Compartilhar no WhatsApp' }) await expect(waItem).toBeVisible() await expect(waItem).toHaveAttribute('target', '_blank') await expect(waItem).toHaveAttribute('rel', 'noopener noreferrer') expect(await waMessage(waItem)).toBe( `Olha isso do Solla: ${articleTitle} — ${baseURL}/noticia/${shareTagSlug}/e2e-artigo-compartilhar-${runSuffix}`, ) await page.keyboard.press('Escape') await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) // Copy link puts the absolute URL in the clipboard and swaps the label. await articleShare.click() await page.getByRole('button', { name: /Copiar link/ }).click() await expect(page.getByRole('button', { name: 'Link copiado' })).toBeVisible() await expect .poll(() => page.evaluate(() => navigator.clipboard.readText())) .toBe(`${baseURL}/noticia/${shareTagSlug}/e2e-artigo-compartilhar-${runSuffix}`) await page.keyboard.press('Escape') await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) // Outside click also closes the popover. await articleShare.click() await section.getByRole('heading', { name: 'A caminhada, em tempo real' }).click() await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) // Video card: the platform URL is shared with the video opener. const videoCard = section.getByRole('link', { name: /E2e Vídeo em destaque/ }) const videoShare = videoCard.locator('xpath=..').getByRole('button', { name: 'Compartilhar' }) await videoShare.click() expect(await waMessage(page.getByRole('link', { name: 'Compartilhar no WhatsApp' }))).toBe( 'Olha esse vídeo do Solla: E2e Vídeo em destaque — https://www.youtube.com/watch?v=e2evideo001', ) await page.keyboard.press('Escape') await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) // Instagram card: the post opener. const instagramCard = section.getByRole('link', { name: /E2e Post do muro/ }) const instagramShare = instagramCard .locator('xpath=..') .getByRole('button', { name: 'Compartilhar' }) await instagramShare.click() expect(await waMessage(page.getByRole('link', { name: 'Compartilhar no WhatsApp' }))).toBe( 'Olha esse post do Solla: E2e Post do muro — https://www.instagram.com/p/e2e-ig-muro-1/', ) await page.keyboard.press('Escape') // Mobile: the carousel exposes only the active card's share button, with // a ≥44px touch target around the discreet 28px visual, and the portal // menu stays fully inside the viewport (never clipped by the track). await page.setViewportSize({ width: 390, height: 844 }) await gotoHomeFresh(page) const carousel = page.getByRole('region', { name: 'Conteúdos recentes' }) await expect(carousel).toBeVisible() // The active (featured) card is the newest item — the article seeded // 5 min ago against the stubs' 20-min-oldest — which pins the identity // of the single exposed share button below. await expect(carousel.getByRole('heading', { name: articleTitle })).toBeVisible() const mobileShare = carousel.getByRole('button', { name: 'Compartilhar' }) await expect(mobileShare).toHaveCount(1) await mobileShare.scrollIntoViewIfNeeded() const buttonBox = await mobileShare.boundingBox() expect(buttonBox).not.toBeNull() expect(buttonBox!.width).toBeGreaterThanOrEqual(44) expect(buttonBox!.height).toBeGreaterThanOrEqual(44) const visualBox = await mobileShare.locator('span').first().boundingBox() expect(visualBox).not.toBeNull() expect(Math.abs(visualBox!.width - 28)).toBeLessThanOrEqual(1) expect(Math.abs(visualBox!.height - 28)).toBeLessThanOrEqual(1) await mobileShare.click() const mobileWaItem = page.getByRole('link', { name: 'Compartilhar no WhatsApp' }) await expect(mobileWaItem).toBeVisible() const mobileWaBox = await mobileWaItem.boundingBox() expect(mobileWaBox).not.toBeNull() expect(mobileWaBox!.x).toBeGreaterThanOrEqual(0) expect(mobileWaBox!.x + mobileWaBox!.width).toBeLessThanOrEqual(390) expect(await waMessage(mobileWaItem)).toBe( `Olha isso do Solla: ${articleTitle} — ${baseURL}/noticia/${shareTagSlug}/e2e-artigo-compartilhar-${runSuffix}`, ) await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') await cleanupS2Fixtures(request, headers) } }) }) test.describe('Cards personalizados (S13)', () => { test('home invites to the card funnel right after the newsletter capture', async ({ page }) => { await page.goto('/') const order = await page .locator('[data-home-section]') .evaluateAll((sections) => sections.map((section) => section.getAttribute('data-home-section')), ) expect(order.indexOf('cards')).toBe(order.indexOf('newsletter') + 1) const section = page.locator('section#cards') await expect( section.getByRole('heading', { name: 'Mostre que você está com Solla' }), ).toBeVisible() for (const model of ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular']) { await expect(section.locator(`a[href="/cards?model=${model}"]`).first()).toBeAttached() } const trigger = section.getByRole('link', { name: /Moldura quadrada/ }) await trigger.click() await expect(page).toHaveURL(/\/cards\?model=perfil-quadrado$/) const dialog = page.getByRole('dialog') await expect(dialog.getByRole('heading', { name: 'Enquadre sua foto' })).toBeVisible() await page.keyboard.press('Escape') await expect(page.getByRole('dialog')).toHaveCount(0) await expect( page.locator('[data-card-model-tile="perfil-quadrado"]:visible').first(), ).toBeFocused() }) test('name card fits the master, reaches the result and downloads a real PNG', async ({ page, }) => { await page.goto('/cards') await expect(page.getByRole('dialog')).toHaveCount(0) await page.getByRole('button', { name: 'Card com seu nome' }).click() const dialog = page.getByRole('dialog') await expect(dialog.getByRole('heading', { name: 'Personalize com seu nome' })).toBeVisible() const primary = dialog.getByRole('button', { name: 'Criar meu card' }) await expect(primary).toBeDisabled() await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('João') await expect(primary).toBeEnabled() const canvas = dialog.locator('canvas') await expect(canvas).toHaveAttribute('width', '1080') await expect(canvas).toHaveAttribute('height', '1440') await expect .poll(() => canvas.evaluate((element) => { const node = element as HTMLCanvasElement const band = node.getContext('2d')!.getImageData(0, 420, node.width, 120).data let yellow = 0 for (let index = 0; index < band.length; index += 4) { if (band[index] > 230 && band[index + 1] > 200 && band[index + 2] < 80) yellow += 1 } return yellow }), ) .toBeGreaterThan(1000) await primary.click() await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible() const [download] = await Promise.all([ page.waitForEvent('download'), dialog.getByRole('button', { name: 'Baixar meu card' }).click(), ]) expect(download.suggestedFilename()).toBe('card-jorge-solla-eu-sou-solla.png') const downloadPath = await download.path() expect(downloadPath).toBeTruthy() expect(readFileSync(downloadPath!).subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true) }) test('a name that cannot fit fails closed with an actionable message', async ({ page }) => { await page.goto('/cards?model=eu-sou-solla') const dialog = page.getByRole('dialog') await expect(dialog).toBeVisible() await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Anticonstitucionalissimamente') await expect(dialog.getByRole('alert')).toContainText('nome mais curto') await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeDisabled() }) test('photo card decodes the file, keeps the overlay and recovers from a bad image', async ({ page, }) => { await page.goto('/cards?model=perfil-quadrado') const dialog = page.getByRole('dialog') const canvas = dialog.locator('canvas') await expect(canvas).toHaveAttribute('width', '1000') await expect(canvas).toHaveAttribute('height', '1000') await dialog.locator('input[type="file"]').setInputFiles({ name: 'foto-e2e.png', mimeType: 'image/png', buffer: TEST_PHOTO_PNG, }) await expect(dialog.getByRole('button', { name: 'Trocar foto' })).toBeVisible() await expect .poll(() => canvas.evaluate((element) => { const node = element as HTMLCanvasElement return node.getContext('2d')!.getImageData(5, 5, 1, 1).data[3] }), ) .toBe(255) await dialog.locator('input[type="file"]').setInputFiles({ name: 'nao-e-foto.txt', mimeType: 'text/plain', buffer: Buffer.from('not an image'), }) await expect(dialog.getByRole('alert')).toContainText('Não foi possível usar esta foto') await expect(dialog.getByRole('button', { name: 'Escolher outra foto' })).toBeVisible() }) test('mobile opens the composer in the bottom drawer', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/cards?model=eu-sou-solla') const drawer = page.locator('[data-slot="drawer-popup"]') await expect(drawer).toBeVisible() await expect(drawer.getByRole('heading', { name: 'Personalize com seu nome' })).toBeVisible() await page.keyboard.press('Escape') await expect(drawer).toHaveCount(0) }) })