mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:25:18 +00:00
- font: espera a família primária (check falhava pela fallback do next/font) - load de assets/fonte separado do redraw (sem flicker/live-region por tecla) - pan/zoom com updates funcionais; photoSize memoizado - foco volta ao tile do catálogo ao fechar (trigger da home desmonta) - copy de privacidade centralizada; tile deriva dims/cor do modelo - aria do input de arquivo sem colidir com o botão; +2 unit tests
1788 lines
74 KiB
TypeScript
1788 lines
74 KiB
TypeScript
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<HTMLElement>('[data-home-section="hero"]')
|
||
const proof = document.querySelector<HTMLElement>('[data-home-section="proof"]')
|
||
const scrollContainer = document.querySelector<HTMLElement>('[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<HTMLElement>(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<HTMLElement>(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<HTMLElement>(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<HTMLElement>('[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<HTMLElement>(selector)
|
||
if (!element) throw new Error(`Seção ausente: ${selector}`)
|
||
return element.getBoundingClientRect()
|
||
}
|
||
const gridBottom = (selector: string) => {
|
||
const grid = document.querySelector<HTMLElement>(selector)
|
||
if (!grid) throw new Error(`Grade ausente: ${selector}`)
|
||
const cards = Array.from(grid.querySelectorAll<HTMLElement>('li'))
|
||
return Math.max(...cards.map((card) => card.getBoundingClientRect().bottom))
|
||
}
|
||
const gridClipped = (selector: string) => {
|
||
const grid = document.querySelector<HTMLElement>(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<HTMLElement>('[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<HTMLElement>(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<HTMLElement>('[data-home-section="story"]')
|
||
if (!storySection) throw new Error('Seção ausente')
|
||
const copy = storySection.querySelector<HTMLElement>('div.max-w-md')
|
||
const frame = storySection.querySelector<HTMLElement>('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<HTMLElement>('[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<string, string>,
|
||
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<string, string>,
|
||
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<string, string>,
|
||
data: Record<string, unknown>,
|
||
) => {
|
||
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<string, string>,
|
||
) => {
|
||
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<string, string>) => {
|
||
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)
|
||
})
|
||
})
|