diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7b870a2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Texto sempre com LF no repositório e na cópia de trabalho (o Windows com core.autocrlf=true gerava CRLF e o Biome acusava) +* text=auto eol=lf + +# Binários: nunca converter quebras de linha +*.mp4 binary +*.webm binary +*.webp binary +*.jpg binary +*.png binary +*.ico binary +*.pdf binary diff --git a/e2e/client-strip.spec.ts b/e2e/client-strip.spec.ts new file mode 100644 index 0000000..62d0357 --- /dev/null +++ b/e2e/client-strip.spec.ts @@ -0,0 +1,123 @@ +import { expect, type Page, test } from "@playwright/test"; +import { clientProjectNames } from "./helpers"; + +/** + * (i) Faixa "Já entregue para" do hero (ClientStrip), em larguras de celular a computador: o ponto separador (·) só + * aparece ENTRE itens da mesma linha. O ponto abre cada item (menos o primeiro) e a margem negativa o esconde, pelo + * `overflow-x: clip` da lista, quando o item abre uma linha nova. Então, para cada item depois do primeiro: + * ponto visível <=> o item está na mesma linha do anterior. + * Nenhuma linha começa nem termina com "·". O Jest só confere as classes; só o navegador mostra o corte. + * "Visível" = o centro do ponto está dentro da caixa da lista (o que o corte deixa passar). + */ + +const WIDTHS = [320, 360, 390, 430, 560, 768, 1024, 1440]; + +async function stripItems(page: Page) { + return page.evaluate(() => { + const label = Array.from(document.querySelectorAll("p")).find( + (paragraph) => paragraph.textContent?.trim() === "Já entregue para:", + ); + const list = label?.parentElement?.querySelector("ul"); + if (!list) return null; + const listBox = list.getBoundingClientRect(); + const items = Array.from(list.querySelectorAll("li")).map((item) => { + const dot = item.querySelector("[aria-hidden='true']"); + const dotBox = dot?.getBoundingClientRect(); + return { + text: (item.textContent ?? "").replace(/\s+/g, " ").trim(), + // o centro vertical: um item mais baixo (os anos) fica centrado na linha e tem outro topo + center: Math.round( + item.getBoundingClientRect().top + + item.getBoundingClientRect().height / 2, + ), + hasDot: Boolean(dot), + dotCenter: dotBox ? dotBox.left + dotBox.width / 2 : null, + }; + }); + return { + items, + listLeft: listBox.left, + listRight: listBox.right, + overflowX: getComputedStyle(list).overflowX, + }; + }); +} + +/** Confere a regra "ponto visível <=> mesma linha do anterior" na faixa como ela está agora. Devolve quantas quebras há. */ +async function expectDotsOnlyBetweenItemsOfOneLine(page: Page, where: string) { + const strip = await stripItems(page); + expect(strip, `${where}: a faixa 'Já entregue para:' no hero`).not.toBeNull(); + if (!strip) return 0; + const { items, listLeft, listRight, overflowX } = strip; + // 4 clientes + os anos de experiência + expect(items.length, `${where}: itens da faixa`).toBeGreaterThanOrEqual( + clientProjectNames().length, + ); + expect(overflowX, `${where}: o corte do ponto depende de overflow-x`).toBe( + "clip", + ); + expect(items[0].hasDot, `${where}: o primeiro item não tem ponto`).toBe( + false, + ); + + const report = items + .map( + (item) => + `${item.center}px ${item.hasDot ? `ponto em ${Math.round(item.dotCenter ?? 0)}` : "sem ponto"} "${item.text}"`, + ) + .join("\n"); + let wraps = 0; + for (let index = 1; index < items.length; index++) { + const item = items[index]; + const sameLine = Math.abs(item.center - items[index - 1].center) < 12; + if (!sameLine) wraps += 1; + const visible = + item.dotCenter !== null && + item.dotCenter >= listLeft && + item.dotCenter <= listRight; + expect( + visible, + `${where}, "${item.text}": ${sameLine ? "na mesma linha do anterior, o ponto precisa aparecer" : "abre uma linha nova, o ponto precisa ficar escondido"} (lista de ${Math.round(listLeft)} a ${Math.round(listRight)}px)\n${report}`, + ).toBe(sameLine); + } + return wraps; +} + +for (const width of WIDTHS) { + test(`faixa de clientes a ${width}px: o ponto só aparece entre itens da mesma linha`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/", { waitUntil: "load" }); + await page.waitForTimeout(800); + + await expectDotsOnlyBetweenItemsOfOneLine(page, `${width}px`); + }); +} + +test("faixa de clientes em colunas estreitas (quebra forçada): nenhuma linha começa nem termina com o ponto", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/", { waitUntil: "load" }); + await page.waitForTimeout(800); + + let wrappedAtLeastOnce = false; + for (const columnWidth of [140, 200, 260, 320, 380, 440, 520, 600]) { + await page.evaluate((px) => { + const label = Array.from(document.querySelectorAll("p")).find( + (paragraph) => paragraph.textContent?.trim() === "Já entregue para:", + ); + const wrapper = label?.parentElement; + if (wrapper) wrapper.style.maxWidth = `${px}px`; + }, columnWidth); + const wraps = await expectDotsOnlyBetweenItemsOfOneLine( + page, + `coluna de ${columnWidth}px`, + ); + if (wraps > 0) wrappedAtLeastOnce = true; + } + expect(wrappedAtLeastOnce, "alguma largura quebrou a faixa em linhas").toBe( + true, + ); +}); diff --git a/e2e/contact.spec.ts b/e2e/contact.spec.ts new file mode 100644 index 0000000..ebd88e9 --- /dev/null +++ b/e2e/contact.spec.ts @@ -0,0 +1,167 @@ +import { expect, type Page, test } from "@playwright/test"; +import { DESKTOP } from "./helpers"; + +/** + * (h) Contato, no computador (1440x900): + * - o botão de cada oferta da home ("Escolha por onde começar") leva o formulário (#contact) para a tela, com o tipo + * de projeto da oferta já escolhido, e um clique seguinte em "Falar sobre meu projeto" do cabeçalho continua + * rolando até #contact (a página rola para outro lugar entre os dois cliques); + * - de /privacidade, o botão do cabeçalho leva a /#contact e deixa o foco dentro de #contact; + * - com movimento reduzido, dois cliques seguidos no link /#contact do cabeçalho rolam nos dois. + * "Formulário na tela": o título do contato e o campo "Tipo de projeto" ficam inteiros dentro da janela e abaixo do + * cabeçalho fixo. + */ + +const CTA_LABEL = "Falar sobre meu projeto"; + +/** + * Tipo de cada oferta (o `?tipo=` do href do botão) -> opção que o formulário mostra marcada. Espelha CONTACT_KINDS + * (app/components/contact-subject.ts): uma oferta nova sem entrada aqui reprova o primeiro teste. + */ +const SUBJECT_BY_KIND = { + site: "Site para empresa", + sistema: "Sistemas web sob medida", + revisao: "Revisão ou diagnóstico de sistema", +} as const; + +test.use({ viewport: DESKTOP }); + +/** Abre a página e espera o tempo de a hidratação assentar, como os demais testes de interação. */ +async function open(page: Page, path: string) { + await page.goto(path, { waitUntil: "load" }); + await page.waitForTimeout(1500); +} + +const headerContact = (page: Page) => + page.locator("header").getByRole("link", { name: CTA_LABEL }); + +const scrollToTop = (page: Page) => + page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" })); + +/** "ok" se o título e o campo de tipo do contato estão inteiros na janela e abaixo do cabeçalho; senão, onde estão. */ +function contactFormOnScreen(page: Page) { + return page.evaluate(() => { + const box = (selector: string) => { + const rect = document.querySelector(selector)?.getBoundingClientRect(); + return rect + ? { top: Math.round(rect.top), bottom: Math.round(rect.bottom) } + : null; + }; + const headerBottom = Math.round( + document.querySelector("header")?.getBoundingClientRect().bottom ?? 0, + ); + const parts = { + título: box("#contact h2"), + "campo de tipo": box("#contact select[name='subject']"), + }; + const hidden = Object.entries(parts).filter( + ([, rect]) => + !rect || rect.top < headerBottom - 1 || rect.bottom > innerHeight + 1, + ); + return hidden.length === 0 + ? "ok" + : `fora da tela: ${hidden.map(([name]) => name).join(", ")}; ${JSON.stringify({ ...parts, headerBottom, janela: innerHeight, rolagem: Math.round(scrollY) })}`; + }); +} + +/** Espera a rolagem (suave ou não) levar o formulário de contato para a tela. */ +async function expectContactOnScreen(page: Page, what: string) { + await expect + .poll(() => contactFormOnScreen(page), { + message: `${what}: o formulário de contato na tela`, + timeout: 8_000, + }) + .toBe("ok"); +} + +test("a home tem as três ofertas e cada uma tem o seu tipo de projeto", async ({ + page, +}) => { + await open(page, "/"); + const hrefs = await page + .locator("#modalities a[href*='?tipo=']") + .evaluateAll((links) => links.map((link) => link.getAttribute("href"))); + expect(hrefs.sort()).toEqual( + Object.keys(SUBJECT_BY_KIND) + .map((kind) => `/?tipo=${kind}#contact`) + .sort(), + ); +}); + +for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) { + test(`oferta "${kind}": o botão leva o contato à tela com "${subject}" marcado, e o botão do cabeçalho rola de novo até ele`, async ({ + page, + }) => { + await open(page, "/"); + const offer = page.locator(`#modalities a[href='/?tipo=${kind}#contact']`); + await offer.scrollIntoViewIfNeeded(); + await offer.click(); + + await expectContactOnScreen(page, `depois da oferta "${kind}"`); + await expect(page.locator("#contact select[name='subject']")).toHaveValue( + subject, + ); + + // a pessoa lê outro trecho da página e usa o botão do cabeçalho: ele ainda rola até o contato + await scrollToTop(page); + await expect.poll(() => contactFormOnScreen(page)).not.toBe("ok"); + await headerContact(page).click(); + await expectContactOnScreen(page, "depois do clique no cabeçalho"); + // e o tipo escolhido pela oferta continua marcado + await expect(page.locator("#contact select[name='subject']")).toHaveValue( + subject, + ); + }); +} + +test("de /privacidade, o botão do cabeçalho leva a /#contact e deixa o foco dentro de #contact", async ({ + page, +}) => { + await open(page, "/privacidade"); + await headerContact(page).click(); + await page.waitForURL(/\/#contact$/); + + await expect + .poll( + () => + page.evaluate(() => { + const contact = document.getElementById("contact"); + const active = document.activeElement; + return contact !== null && active !== null && contact.contains(active) + ? "dentro de #contact" + : `fora de #contact: ${active?.tagName.toLowerCase()}${active?.id ? `#${active.id}` : ""}`; + }), + { message: "foco depois de chegar a /#contact", timeout: 8_000 }, + ) + .toBe("dentro de #contact"); +}); + +test.describe("com movimento reduzido", () => { + test.use({ reducedMotion: "reduce" }); + + test("na home, dois cliques seguidos no link /#contact do cabeçalho rolam nos dois", async ({ + page, + }) => { + await open(page, "/"); + for (const click of ["primeiro", "segundo"]) { + await scrollToTop(page); + await expect.poll(() => contactFormOnScreen(page)).not.toBe("ok"); + await headerContact(page).click(); + await expectContactOnScreen(page, `${click} clique`); + } + }); + + test("chegando de /privacidade, o clique no cabeçalho e um segundo clique na home rolam nos dois", async ({ + page, + }) => { + await open(page, "/privacidade"); + await headerContact(page).click(); + await page.waitForURL(/\/#contact$/); + await expectContactOnScreen(page, "primeiro clique (de /privacidade)"); + + await scrollToTop(page); + await expect.poll(() => contactFormOnScreen(page)).not.toBe("ok"); + await headerContact(page).click(); + await expectContactOnScreen(page, "segundo clique (já na home)"); + }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 27d8189..f7f684b 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -17,6 +17,25 @@ export async function sitemapPaths(request: APIRequestContext) { return [...new Set(paths)]; } +let devServer: Promise | undefined; + +/** + * O `BASE_URL` é um servidor de desenvolvimento (`next dev`)? O HTML dele traz o cliente de recarga a quente (HMR) e o + * React Refresh, que o build de produção não tem. Serve para as poucas coisas que só existem em um dos dois: a barra + * de cookies (só em produção) e avisos do React (só no desenvolvimento). + */ +export function isDevServer(request: APIRequestContext) { + devServer ??= request + .get("/") + .then((response) => response.text()) + .then((html) => /hmr-client|react-refresh/.test(html)) + .catch((error) => { + devServer = undefined; + throw error; + }); + return devServer; +} + /** Nomes dos projetos de cliente, como a home os lista (public/portfolio-data.json, kind "cliente"). */ export function clientProjectNames() { const data = JSON.parse( diff --git a/e2e/keyboard.spec.ts b/e2e/keyboard.spec.ts index 9e4932d..5357321 100644 --- a/e2e/keyboard.spec.ts +++ b/e2e/keyboard.spec.ts @@ -5,15 +5,26 @@ import { DESKTOP } from "./helpers"; * (c) Teclado, no computador: * (a) atalho: Tab até "Falar sobre meu projeto" no cabeçalho, Enter, e o campo de nome do contato em até 4 Tabs. * Só no computador: no celular o botão do hero abre o WhatsApp, não o formulário. - * (b) caminho linear: do topo da home, o Tab chega ao campo de nome do contato em até 40 toques - * (a vitrine de projetos é uma parada só, navegada com as setas). + * (b) caminho linear: do topo da home, o Tab chega ao campo de nome do contato em até 36 toques + * (a vitrine de projetos é navegada com as setas: um seletor e o link do case; ver o inventário em LINEAR_MAX_TABS). * Os testes continuam apertando Tab depois do limite só para dizer, na falha, quantos toques faltavam. */ const CTA_LABEL = "Falar sobre meu projeto"; const SHORTCUT_MAX_TABS_TO_CTA = 20; const SHORTCUT_MAX_TABS_AFTER_ENTER = 4; -const LINEAR_MAX_TABS = 40; +/** + * Limite do caminho linear: 36 toques. Inventário das paradas legítimas, do topo da home ao campo de nome (35 hoje): + * 1 link "Pular para o conteúdo"; + * cabeçalho: logo, 5 links (Cases, Serviços, Como trabalho, Sobre, IA) e "Falar sobre meu projeto" (7); + * hero: "Falar sobre meu projeto", "Ver projetos entregues", os 4 clientes da faixa, LinkedIn e e-mail (8); + * vitrine de projetos: o seletor, o link do case do projeto aberto e "Ver os cases" (3); + * ofertas: 3 botões e 3 links de case/página, mais "Ver todos os serviços" (7); + * "Ver trajetória completa" (1) e as 5 perguntas da FAQ (5); + * contato: WhatsApp e e-mail (2), e então o campo de nome. + * Tudo isso é conteúdo e navegação que a pessoa quer alcançar; o limite não deve subir sem uma parada nova justificada. + */ +const LINEAR_MAX_TABS = 36; const LINEAR_GIVE_UP_AFTER = 120; test.use({ viewport: DESKTOP }); diff --git a/e2e/mobile-menu.spec.ts b/e2e/mobile-menu.spec.ts index 19568d7..39c7e9f 100644 --- a/e2e/mobile-menu.spec.ts +++ b/e2e/mobile-menu.spec.ts @@ -1,29 +1,34 @@ import { expect, type Page, test } from "@playwright/test"; -import { PHONE } from "./helpers"; +import { isDevServer, PHONE } from "./helpers"; /** * (g) Menu do celular (390x844), numa visita nova (no build de produção a barra de cookies está aberta, fixa na base da tela): * - o menu abre; * - o botão de contato do menu não fica sob a barra de cookies (o elemento no ponto do botão é o próprio botão); - * - o Tab fica preso no cabeçalho com o menu aberto, nos dois sentidos; + * - o Tab fica preso no cabeçalho e na barra de cookies com o menu aberto, nos dois sentidos, e a volta passa pelos + * três controles da barra: contato -> "Política de privacidade" -> Aceitar -> Recusar -> logo -> "Fechar menu"; * - Esc fecha o menu e o foco volta ao botão do menu; * - navegar por um link do menu fecha o menu. */ const CTA_LABEL = "Falar sobre meu projeto"; +const LOGO_LABEL = "Evandro.dev.br — início"; +const CLOSE_LABEL = "Fechar menu"; +/** Os três controles da barra de cookies, na ordem do Tab. */ +const BAR_CONTROLS = ["Política de privacidade", "Aceitar", "Recusar"]; + +/** `run` aparece em `sequence`, em posições seguidas? */ +function hasRun(sequence: string[], run: string[]) { + return sequence.some((_, start) => + run.every((label, offset) => sequence[start + offset] === label), + ); +} test.use({ viewport: PHONE }); test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "load" }); - // o botão do menu só responde ao clique depois da hidratação: o React põe nele as props do componente - await page.waitForFunction(() => { - const button = document.querySelector("header button"); - return ( - button !== null && - Object.keys(button).some((key) => key.startsWith("__reactProps")) - ); - }); + await waitUntilInteractive(page); }); const openMenuButton = (page: Page) => @@ -33,6 +38,18 @@ const closeMenuButton = (page: Page) => const mobileNav = (page: Page) => page.getByRole("navigation", { name: "Navegação mobile" }); +/** + * Espera o cabeçalho responder ao clique (a hidratação terminou), só pelo que o usuário vê: abre o menu pelo botão, + * repetindo o clique enquanto ele não responde, e fecha de novo pelo mesmo botão. Deixa o menu fechado. + */ +async function waitUntilInteractive(page: Page) { + await expect(openMenuButton(page)).toBeVisible(); + await openMenu(page); + await closeMenuButton(page).click(); + await expect(openMenuButton(page)).toHaveAttribute("aria-expanded", "false"); + await expect(mobileNav(page)).toHaveCount(0); +} + async function openMenu(page: Page) { // repete o clique enquanto o botão ainda for "Abrir menu": uma recarga do servidor de desenvolvimento (HMR) no meio // do teste, ou um clique antes da hidratação, não deixa o menu aberto @@ -48,6 +65,52 @@ async function openMenu(page: Page) { await expect(mobileNav(page)).toBeVisible(); } +/** + * A barra de cookies da página (id `cookie-bar`, `COOKIE_BAR_ID` em lib/analytics.ts). Ela só é renderizada no build + * de produção (GoogleAnalytics.tsx). Num servidor de desenvolvimento entra no lugar dela uma barra de mesmo contrato + * (id, fixa na base, 96 px, `--cookie-bar-height` publicada na raiz, antes do cabeçalho, com o link da política, + * Aceitar e Recusar), para os testes conferirem o lado do cabeçalho. Num build de produção a ausência da barra real + * reprova. + */ +async function cookieBar(page: Page) { + const bar = page.getByRole("dialog", { name: "Cookies e privacidade" }); + const isReal = await bar.waitFor({ state: "visible", timeout: 3_000 }).then( + () => true, + () => false, + ); + if (isReal) return bar; + + expect( + await isDevServer(page.request), + "a barra de cookies não apareceu num build de produção", + ).toBe(true); + await page.evaluate(() => { + const stand = document.createElement("section"); + stand.id = "cookie-bar"; + stand.setAttribute("role", "dialog"); + stand.setAttribute("aria-label", "Cookies e privacidade"); + stand.style.cssText = + "position:fixed;inset-inline:0;bottom:0;height:96px;z-index:9980;background:#0a0a0a;color:#fff"; + const text = document.createElement("p"); + text.append("Usamos métricas de navegação do Google só se você permitir. "); + const policy = document.createElement("a"); + policy.href = "/privacidade"; + policy.textContent = "Política de privacidade"; + text.append(policy); + const accept = document.createElement("button"); + accept.type = "button"; + accept.textContent = "Aceitar"; + const decline = document.createElement("button"); + decline.type = "button"; + decline.textContent = "Recusar"; + stand.append(text, accept, decline); + document.querySelector("header")?.before(stand); + document.documentElement.style.setProperty("--cookie-bar-height", "96px"); + }); + await expect(bar).toBeVisible(); + return bar; +} + test("o menu abre pelo botão e mostra os links e o contato", async ({ page, }) => { @@ -63,22 +126,7 @@ test("o menu abre pelo botão e mostra os links e o contato", async ({ test("o botão de contato do menu não fica sob a barra de cookies", async ({ page, }) => { - const bar = page.getByRole("dialog", { name: "Cookies e privacidade" }); - // A barra só é renderizada no build de produção (GoogleAnalytics.tsx). Num servidor de desenvolvimento entra no - // lugar dela uma barra de mesmo contrato (fixa na base, 96 px, --cookie-bar-height publicada na raiz), para o - // teste conferir o lado do cabeçalho. No CI (build de produção) a ausência da barra real reprova. - if ((await bar.count()) === 0 && !process.env.CI) { - await page.evaluate(() => { - const stand = document.createElement("section"); - stand.setAttribute("role", "dialog"); - stand.setAttribute("aria-label", "Cookies e privacidade"); - stand.style.cssText = - "position:fixed;inset-inline:0;bottom:0;height:96px;z-index:9980;background:#0a0a0a;color:#fff"; - stand.textContent = "Usamos métricas de navegação do Google"; - document.body.append(stand); - document.documentElement.style.setProperty("--cookie-bar-height", "96px"); - }); - } + const bar = await cookieBar(page); await openMenu(page); await expect(bar).toBeVisible(); @@ -120,9 +168,10 @@ test("o botão de contato do menu não fica sob a barra de cookies", async ({ } }); -test("com o menu aberto o Tab e o Shift+Tab ficam presos no cabeçalho", async ({ +test("com o menu aberto o Tab e o Shift+Tab ficam presos no cabeçalho e na barra de cookies", async ({ page, }) => { + await cookieBar(page); await openMenu(page); const header = page.locator("header"); @@ -139,27 +188,50 @@ test("com o menu aberto o Tab e o Shift+Tab ficam presos no cabeçalho", async ( .replace(/\s+/g, " ") .slice(0, 40); return { - insideHeader: Boolean(element?.closest("header")), - label: `${element?.tagName.toLowerCase()} "${label}"`, + // o cabeçalho e a barra de cookies (id cookie-bar, COOKIE_BAR_ID) formam o ciclo do menu + inside: Boolean(element?.closest("header, #cookie-bar")), + label, }; }); + // o ciclo, na ordem do Tab: a barra (vem antes do cabeçalho no layout), logo, botão do menu, 5 links, contato + const cycle = [ + ...BAR_CONTROLS, + LOGO_LABEL, + CLOSE_LABEL, + "Cases", + "Serviços", + "Como trabalho", + "Sobre", + "IA", + CTA_LABEL, + ]; + // a volta que mais importa: do contato à barra de cookies, ao logo e ao botão do menu + const wrap = [CTA_LABEL, ...BAR_CONTROLS, LOGO_LABEL, CLOSE_LABEL]; + for (const key of ["Tab", "Shift+Tab"]) { - const visited = new Set(); - // mais toques do que controles no cabeçalho (logo, botão, 5 links, contato): dá a volta inteira, mais de uma vez - for (let press = 1; press <= 20; press++) { + const sequence: string[] = []; + // mais toques do que controles no ciclo: dá a volta inteira, mais de uma vez + for (let press = 1; press <= cycle.length * 2; press++) { await page.keyboard.press(key); - const { insideHeader, label } = await whereIsFocus(); + const { inside, label } = await whereIsFocus(); expect( - insideHeader, - `${key} nº ${press} levou o foco para fora do cabeçalho: ${label}`, + inside, + `${key} nº ${press} levou o foco para fora do cabeçalho e da barra de cookies: ${label}`, ).toBe(true); - visited.add(label); + sequence.push(label); } + const visited = new Set(sequence); + const missing = cycle.filter((label) => !visited.has(label)); expect( - visited.size, - `${key} percorreu ${visited.size} controles: ${[...visited].join("; ")}`, - ).toBeGreaterThanOrEqual(8); + missing, + `${key} não passou por: ${missing.join("; ")}; passou por: ${[...visited].join("; ")}`, + ).toEqual([]); + const expected = key === "Tab" ? wrap : [...wrap].reverse(); + expect( + hasRun(sequence, expected), + `${key} deveria andar ${expected.join(" -> ")}; andou ${sequence.join(" -> ")}`, + ).toBe(true); } await expect(header.getByRole("link", { name: CTA_LABEL })).toBeVisible(); }); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index 2d4662d..64a821a 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -47,7 +47,10 @@ test("menu Cases e depois um case: sem recarregar o documento, uma transição d await page.waitForTimeout(500); expect(await transitions(), "depois de Cases").toBe(1); - await page.getByRole("link", { name: "Ver o case" }).first().click(); + await page + .getByRole("link", { name: /^Ver case / }) + .first() + .click(); await page.waitForURL(/\/cases\/[^/]+$/); await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); await page.waitForTimeout(500); diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts index f0bc39e..67ac09a 100644 --- a/e2e/routes.spec.ts +++ b/e2e/routes.spec.ts @@ -1,13 +1,22 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; -import { DESKTOP, PHONE, sitemapPaths } from "./helpers"; +import { DESKTOP, isDevServer, PHONE, sitemapPaths } from "./helpers"; /** * (a) Todas as rotas do /sitemap.xml respondem 200, sem erro de console e sem rolagem horizontal, no computador * (1440x900) e no celular (390x844). * (b) O axe não acha violação serious ou critical em nenhuma delas, nos mesmos dois tamanhos. * As rotas vêm do próprio sitemap: uma página nova entra no teste sozinha. + * + * Erro de console é falha, sem exceção em produção. Única exceção, só quando o BASE_URL é um servidor de + * desenvolvimento (`next dev`, detectado por isDevServer): o aviso do React "Each child in a list should have a + * unique "key" prop", que só existe no React de desenvolvimento (o build de produção não o emite) e que a pilha + * atribui a `OuterLayoutRouter`, do roteador do próprio Next, e não a um componente do site. Ele não é + * esquecido: cada rota em que aparece fica anotada no relatório do teste ("aviso do next dev ignorado"). Qualquer + * outro erro de console reprova nos dois modos. */ +const DEV_ONLY_KEY_WARNING = + /Each child in a list should have a unique "key" prop/; const VIEWPORTS = [ { name: "computador 1440x900", size: DESKTOP }, @@ -24,6 +33,8 @@ for (const { name, size } of VIEWPORTS) { }) => { const paths = await sitemapPaths(request); expect(paths.length, "rotas no sitemap").toBeGreaterThan(10); + const dev = await isDevServer(request); + const ignoredWarnings = new Set(); for (const path of paths) { await test.step(path, async () => { @@ -31,8 +42,12 @@ for (const { name, size } of VIEWPORTS) { const onConsole = ( message: import("@playwright/test").ConsoleMessage, ) => { - if (message.type() === "error") - problems.push(`console.error: ${message.text()}`); + if (message.type() !== "error") return; + if (dev && DEV_ONLY_KEY_WARNING.test(message.text())) { + ignoredWarnings.add(path); + return; + } + problems.push(`console.error: ${message.text()}`); }; const onPageError = (error: Error) => problems.push(`erro não tratado: ${error.message}`); @@ -60,6 +75,11 @@ for (const { name, size } of VIEWPORTS) { .toBeLessThanOrEqual(0); }); } + for (const path of ignoredWarnings) + test.info().annotations.push({ + type: "aviso do next dev ignorado", + description: `${path}: Each child in a list should have a unique "key" prop`, + }); }); }); } diff --git a/e2e/scroll.spec.ts b/e2e/scroll.spec.ts index 37eba6b..bf93488 100644 --- a/e2e/scroll.spec.ts +++ b/e2e/scroll.spec.ts @@ -1,20 +1,22 @@ import { expect, type Page, test } from "@playwright/test"; -import { DESKTOP } from "./helpers"; +import { DESKTOP, PHONE } from "./helpers"; /** - * (f) No computador, a home não tem trecho vazio: amostrada a cada ¼ de tela de rolagem, nenhuma sequência de - * amostras sem texto cobre ½ tela ou mais de rolagem (3 amostras seguidas vazias ou mais). Uma amostra vazia - * isolada, ou duas seguidas (menos de ½ tela), é o instante de uma troca de cena e não reprova. + * (f) A home não tem trecho vazio, no computador (1440x900) e no celular (390x844): amostrada a cada ¼ de tela de + * rolagem, do topo ao fim, TODA amostra tem texto visível. Tolerância zero: nem uma amostra isolada sem texto passa + * (uma troca de cena que deixe a tela vazia por um instante, no ponto exato de uma amostra, reprova e conta como + * defeito da vitrine, não do teste). * Uma amostra é "vazia" quando não há nenhum elemento de texto visível na janela. Cabeçalho, avisos, leitores de * tela e itens ocultos não contam (o cabeçalho fixo estaria em todas as amostras e esconderia uma tela vazia). */ /** Passo da amostragem, em fração da altura da janela. */ const STEP = 1 / 4; -/** Trecho vazio (em fração da altura da janela) a partir do qual o teste reprova. */ -const MAX_EMPTY_SPAN = 1 / 2; -test.use({ viewport: DESKTOP }); +const VIEWPORTS = [ + { name: "computador 1440x900", size: DESKTOP }, + { name: "celular 390x844", size: PHONE }, +] as const; interface Sample { top: number; @@ -90,38 +92,31 @@ async function sampleHome(page: Page) { return { samples, step, maxScroll, viewportHeight }; } -/** Sequências de amostras vazias seguidas cuja rolagem (da primeira à última da sequência) chega a `minSpan`. */ -function emptyRuns(samples: Sample[], minSpan: number) { - const runs: string[] = []; - let start = -1; - for (const [index, { count }] of samples.entries()) { - if (count === 0 && start < 0) start = index; - const endsRun = count > 0 || index === samples.length - 1; - if (start >= 0 && endsRun) { - const last = count === 0 ? index : index - 1; - const span = samples[last].top - samples[start].top; - if (span >= minSpan) - runs.push(`${samples[start].top}px a ${samples[last].top}px`); - start = -1; - } - } - return runs; +for (const { name, size } of VIEWPORTS) { + test.describe(`rolagem da home, ${name}`, () => { + test.use({ viewport: size }); + + test("amostrada a cada ¼ de tela, nenhuma posição fica sem texto visível", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "load" }); + await page.waitForTimeout(1500); + + const { samples, step, maxScroll, viewportHeight } = + await sampleHome(page); + const empty = samples + .filter(({ count }) => count === 0) + .map( + ({ top }) => `${top}px (${(top / viewportHeight).toFixed(2)} tela)`, + ); + + const log = samples + .map(({ top, count }) => `${top}px: ${count} trecho(s) de texto`) + .join("\n"); + expect( + empty, + `posições sem texto visível (amostras a cada ${step}px, página de ${maxScroll}px, janela de ${viewportHeight}px):\n${log}`, + ).toEqual([]); + }); + }); } - -test("a home, amostrada a cada ¼ de tela, não tem janela de ½ tela ou mais sem texto visível", async ({ - page, -}) => { - await page.goto("/", { waitUntil: "load" }); - await page.waitForTimeout(1500); - - const { samples, step, maxScroll, viewportHeight } = await sampleHome(page); - const gaps = emptyRuns(samples, viewportHeight * MAX_EMPTY_SPAN); - - const log = samples - .map(({ top, count }) => `${top}px: ${count} trecho(s) de texto`) - .join("\n"); - expect( - gaps, - `janelas de ½ tela ou mais sem texto visível (amostras a cada ${step}px, página de ${maxScroll}px):\n${log}`, - ).toEqual([]); -}); diff --git a/e2e/sev-poster.spec.ts b/e2e/sev-poster.spec.ts new file mode 100644 index 0000000..6fd9950 --- /dev/null +++ b/e2e/sev-poster.spec.ts @@ -0,0 +1,80 @@ +import { expect, type Page, test } from "@playwright/test"; +import { DESKTOP, PHONE } from "./helpers"; + +/** + * (j) O pôster da SEV Moda Íntima não se repete: em /servicos e em /cases ele aparece no máximo 2 vezes (meta da + * rodada 4), contando todo elemento que o mostra ao navegador (,