From 02298779df3fe54b2553679a93d6b79dd9612c0c Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 14 Oct 2026 22:54:27 -0300 Subject: [PATCH] =?UTF-8?q?test(e2e):=20fluxos=20de=20contato,=20navega?= =?UTF-8?q?=C3=A7=C3=A3o,=20teclado=20e=20menu=20do=20celular=20mais=20r?= =?UTF-8?q?=C3=ADgidos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/contact.spec.ts | 119 ++++++++++++++++++++++++++++++---------- e2e/helpers.ts | 58 +++++++++++++++++++- e2e/keyboard.spec.ts | 61 +++++++++++++++++--- e2e/mobile-menu.spec.ts | 72 +++++------------------- e2e/navigation.spec.ts | 90 +++++++++++++++++++++++++++++- e2e/routes.spec.ts | 16 +++--- e2e/scroll.spec.ts | 10 ++-- e2e/sev-poster.spec.ts | 10 ++-- 8 files changed, 321 insertions(+), 115 deletions(-) diff --git a/e2e/contact.spec.ts b/e2e/contact.spec.ts index ebd88e9..661bccc 100644 --- a/e2e/contact.spec.ts +++ b/e2e/contact.spec.ts @@ -1,15 +1,19 @@ import { expect, type Page, test } from "@playwright/test"; -import { DESKTOP } from "./helpers"; +import { DESKTOP_USE, PHONE_USE } 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); + * (h) Contato, no computador (1440x900) e, onde dito, no celular (390x844, toque): + * - o botão de cada oferta da home ("Escolha por onde começar") leva o cartão do formulário (#contact-form) para a + * tela, com o tipo de projeto da oferta já escolhido e o endereço `/#contact-form` (o `?tipo=` sai), 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); + * - o link direto `/?tipo=#contact-form` (quem chega de outra página) abre a home com o formulário na tela e o + * tipo marcado; + * - no celular, o toque no botão da oferta deixa o cartão do formulário e o campo do tipo à vista; * - 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. + * "Formulário na tela": o título do contato e o campo "Tipo de projeto" ficam inteiros dentro da janela, abaixo do + * cabeçalho fixo e acima da barra de cookies, quando ela está aberta (primeira visita, em qualquer servidor). */ const CTA_LABEL = "Falar sobre meu projeto"; @@ -19,12 +23,12 @@ const CTA_LABEL = "Falar sobre meu projeto"; * (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", + site: "Site que traz pedidos pelo WhatsApp", + sistema: "Sistema para tirar a operação da planilha", + revisao: "Revisão de sistema que já existe", } as const; -test.use({ viewport: DESKTOP }); +test.use(DESKTOP_USE); /** 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) { @@ -38,9 +42,13 @@ const headerContact = (page: Page) => 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(() => { +/** + * "ok" se as partes do contato estão inteiras na janela, abaixo do cabeçalho e acima da barra de cookies (se aberta); + * senão, onde estão. `card`: o destino das ofertas (o cartão do formulário e o campo de tipo); sem ele, o destino do + * botão do cabeçalho (o título do contato e o campo de tipo). + */ +function contactFormOnScreen(page: Page, card = false) { + return page.evaluate((onlyCard) => { const box = (selector: string) => { const rect = document.querySelector(selector)?.getBoundingClientRect(); return rect @@ -50,24 +58,39 @@ function contactFormOnScreen(page: Page) { 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']"), - }; + // a barra de cookies fixa na base cobre o que estiver abaixo do topo dela + const barTop = document + .getElementById("cookie-bar") + ?.getBoundingClientRect().top; + const limit = Math.min(innerHeight, barTop ?? innerHeight); + const parts = onlyCard + ? { + "cartão do formulário": box("#contact-form"), + "campo de tipo": box("#contact select[name='subject']"), + } + : { + 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, + ([name, rect]) => + !rect || + rect.top < headerBottom - 1 || + // o cartão inteiro pode ser mais alto que a janela: basta o topo dele estar à vista + (name === "cartão do formulário" + ? rect.top > limit + 1 + : rect.bottom > limit + 1), ); return hidden.length === 0 ? "ok" - : `fora da tela: ${hidden.map(([name]) => name).join(", ")}; ${JSON.stringify({ ...parts, headerBottom, janela: innerHeight, rolagem: Math.round(scrollY) })}`; - }); + : `fora da tela: ${hidden.map(([name]) => name).join(", ")}; ${JSON.stringify({ ...parts, headerBottom, janela: innerHeight, barraDeCookies: barTop ?? null, rolagem: Math.round(scrollY) })}`; + }, card); } /** Espera a rolagem (suave ou não) levar o formulário de contato para a tela. */ -async function expectContactOnScreen(page: Page, what: string) { +async function expectContactOnScreen(page: Page, what: string, card = false) { await expect - .poll(() => contactFormOnScreen(page), { + .poll(() => contactFormOnScreen(page, card), { message: `${what}: o formulário de contato na tela`, timeout: 8_000, }) @@ -83,24 +106,28 @@ test("a home tem as três ofertas e cada uma tem o seu tipo de projeto", async ( .evaluateAll((links) => links.map((link) => link.getAttribute("href"))); expect(hrefs.sort()).toEqual( Object.keys(SUBJECT_BY_KIND) - .map((kind) => `/?tipo=${kind}#contact`) + .map((kind) => `/?tipo=${kind}#contact-form`) .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 ({ + test(`oferta "${kind}": o botão leva o formulário à tela com "${subject}" marcado, e o botão do cabeçalho rola de novo até o contato`, async ({ page, }) => { await open(page, "/"); - const offer = page.locator(`#modalities a[href='/?tipo=${kind}#contact']`); + const offer = page.locator( + `#modalities a[href='/?tipo=${kind}#contact-form']`, + ); await offer.scrollIntoViewIfNeeded(); await offer.click(); - await expectContactOnScreen(page, `depois da oferta "${kind}"`); + await expectContactOnScreen(page, `depois da oferta "${kind}"`, true); await expect(page.locator("#contact select[name='subject']")).toHaveValue( subject, ); + // o endereço fica na home, no cartão do formulário, sem o ?tipo= (o tipo já está no formulário) + await expect(page).toHaveURL(/\/#contact-form$/); // 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); @@ -114,6 +141,42 @@ for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) { }); } +for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) { + test(`link direto /?tipo=${kind}#contact-form: abre a home com o formulário à vista e "${subject}" marcado`, async ({ + page, + }) => { + await page.goto(`/?tipo=${kind}#contact-form`, { waitUntil: "load" }); + await page.waitForTimeout(1500); + + await expectContactOnScreen(page, `link direto "${kind}"`, true); + await expect(page.locator("#contact select[name='subject']")).toHaveValue( + subject, + ); + }); +} + +test.describe("no celular (toque)", () => { + test.use(PHONE_USE); + + for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) { + test(`oferta "${kind}": o toque deixa o cartão do formulário e o campo de tipo à vista, com "${subject}" marcado`, async ({ + page, + }) => { + await open(page, "/"); + const offer = page.locator( + `#modalities a[href='/?tipo=${kind}#contact-form']`, + ); + await offer.scrollIntoViewIfNeeded(); + await offer.tap(); + + await expectContactOnScreen(page, `toque na oferta "${kind}"`, true); + 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, }) => { diff --git a/e2e/helpers.ts b/e2e/helpers.ts index f7f684b..7f04559 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import type { APIRequestContext } from "@playwright/test"; +import { type APIRequestContext, expect, type Page } from "@playwright/test"; /** Caminhos (`/`, `/cases/…`) de todas as rotas do /sitemap.xml, sem o domínio de produção. */ export async function sitemapPaths(request: APIRequestContext) { @@ -21,8 +21,8 @@ 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). + * React Refresh, que o build de produção não tem. Serve para a única coisa que só existe em um dos dois: avisos do + * React que só o desenvolvimento emite. (A barra de cookies é a mesma nos dois, e o gtag só carrega em produção.) */ export function isDevServer(request: APIRequestContext) { devServer ??= request @@ -55,3 +55,55 @@ export const CONTACT_HEADING = export const DESKTOP = { width: 1440, height: 900 } as const; export const PHONE = { width: 390, height: 844 } as const; + +/** Opções de `test.use` do computador. */ +export const DESKTOP_USE = { viewport: DESKTOP } as const; + +/** + * Opções de `test.use` de um celular de verdade: a janela de 390x844 mais `isMobile` (viewport de celular, `` valendo) e `hasTouch` (toque, `tap()`), e não só uma janela estreita. + */ +export const PHONE_USE = { + viewport: PHONE, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 2, +} as const; + +/** Chave do consentimento no localStorage (`CONSENT_STORAGE_KEY` em lib/analytics.ts). */ +export const CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1"; + +/** Os controles da barra de cookies, na ordem do Tab. */ +export const COOKIE_BAR_CONTROLS = [ + "Política de privacidade", + "Aceitar", + "Recusar", +] as const; + +/** + * Deixa o consentimento já salvo no navegador (como quem aceitou ou recusou numa visita anterior): chamar antes do + * `page.goto`. Com ele a barra de cookies não abre. "denied" por padrão, para o gtag não carregar em produção. + */ +export async function saveConsent( + page: Page, + value: "granted" | "denied" = "denied", +) { + await page.addInitScript( + ([key, stored]) => window.localStorage.setItem(key, stored), + [CONSENT_STORAGE_KEY, value], + ); +} + +/** + * A barra de cookies da primeira visita (id `cookie-bar`, `COOKIE_BAR_ID` em lib/analytics.ts), fixa na base da tela, + * antes do cabeçalho no documento. Ela é a mesma em qualquer servidor, de desenvolvimento ou de produção (só o + * carregamento do gtag depende do ambiente): se não aparecer, o teste reprova. Devolve o localizador da barra. + */ +export async function cookieBar(page: Page) { + const bar = page.getByRole("dialog", { name: "Cookies e privacidade" }); + await expect( + bar, + "a barra de cookies não apareceu na primeira visita", + ).toBeVisible({ timeout: 10_000 }); + return bar; +} diff --git a/e2e/keyboard.spec.ts b/e2e/keyboard.spec.ts index 5357321..c065389 100644 --- a/e2e/keyboard.spec.ts +++ b/e2e/keyboard.spec.ts @@ -1,12 +1,19 @@ import { expect, type Page, test } from "@playwright/test"; -import { DESKTOP } from "./helpers"; +import { + COOKIE_BAR_CONTROLS, + cookieBar, + DESKTOP_USE, + saveConsent, +} 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é 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). + * (b) caminho linear: do topo da home, o Tab chega ao campo de nome do contato em até 36 toques, mais os controles da + * barra de cookies enquanto ela estiver aberta (primeira visita: 36 + 3 = 39; com o consentimento já salvo a barra + * não abre e o limite é 36). 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. */ @@ -25,9 +32,12 @@ const SHORTCUT_MAX_TABS_AFTER_ENTER = 4; * 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; +/** Primeira visita: a barra de cookies abre antes do cabeçalho e soma os controles dela (link da política, Aceitar, Recusar). */ +const LINEAR_MAX_TABS_FIRST_VISIT = + LINEAR_MAX_TABS + COOKIE_BAR_CONTROLS.length; const LINEAR_GIVE_UP_AFTER = 120; -test.use({ viewport: DESKTOP }); +test.use(DESKTOP_USE); /** Descreve o elemento com foco e diz se é o botão de contato do cabeçalho ou o campo de nome do contato. */ async function focusedElement(page: Page) { @@ -56,12 +66,22 @@ async function focusedElement(page: Page) { }, CTA_LABEL); } -/** Visita nova: foco no documento, topo da página. */ -async function openHomeAsNewVisitor(page: Page) { +/** + * Visita nova: foco no documento, topo da página. Com `consent`, o consentimento já estava salvo no navegador (a barra + * de cookies não abre); sem ele é a primeira visita, e a barra de cookies tem de estar aberta. + */ +async function openHomeAsNewVisitor( + page: Page, + consent?: "granted" | "denied", +) { + if (consent) await saveConsent(page, consent); await page.goto("/", { waitUntil: "load" }); await page.waitForTimeout(800); await page.evaluate(() => (document.activeElement as HTMLElement)?.blur()); await page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" })); + const bar = page.getByRole("dialog", { name: "Cookies e privacidade" }); + if (consent) await expect(bar).toHaveCount(0); + else await cookieBar(page); } /** Aperta Tab até o foco satisfazer `stop`, no máximo `giveUpAfter` vezes. `reached` é 0 se nunca satisfez. */ @@ -116,10 +136,37 @@ test(`atalho: Tab até "${CTA_LABEL}" do cabeçalho, Enter, e o campo de nome em await expect(page.locator("#contact input[name='name']")).toBeFocused(); }); -test(`caminho linear: do topo da home, Tab chega ao campo de nome do contato em até ${LINEAR_MAX_TABS} toques`, async ({ +test(`caminho linear, primeira visita (barra de cookies aberta): do topo da home, Tab chega ao campo de nome do contato em até ${LINEAR_MAX_TABS_FIRST_VISIT} toques (${LINEAR_MAX_TABS} + os ${COOKIE_BAR_CONTROLS.length} controles da barra)`, async ({ page, }) => { await openHomeAsNewVisitor(page); + const bar = await cookieBar(page); + expect( + await bar.locator("a[href], button").count(), + "controles focáveis da barra de cookies", + ).toBe(COOKIE_BAR_CONTROLS.length); + + const { reached, visited } = await tabUntil( + page, + ({ isName }) => isName, + LINEAR_GIVE_UP_AFTER, + ); + + expect( + reached, + `o campo de nome não apareceu nem em ${LINEAR_GIVE_UP_AFTER} Tabs`, + ).toBeGreaterThan(0); + expect( + reached, + `o campo de nome só foi alcançado no Tab ${reached} (limite ${LINEAR_MAX_TABS_FIRST_VISIT}); passou por:\n${visited.join("\n")}`, + ).toBeLessThanOrEqual(LINEAR_MAX_TABS_FIRST_VISIT); + await expect(page.locator("#contact input[name='name']")).toBeFocused(); +}); + +test(`caminho linear, com o consentimento já salvo (sem a barra de cookies): Tab chega ao campo de nome do contato em até ${LINEAR_MAX_TABS} toques`, async ({ + page, +}) => { + await openHomeAsNewVisitor(page, "denied"); const { reached, visited } = await tabUntil( page, diff --git a/e2e/mobile-menu.spec.ts b/e2e/mobile-menu.spec.ts index 39c7e9f..06f0324 100644 --- a/e2e/mobile-menu.spec.ts +++ b/e2e/mobile-menu.spec.ts @@ -1,8 +1,9 @@ import { expect, type Page, test } from "@playwright/test"; -import { isDevServer, PHONE } from "./helpers"; +import { COOKIE_BAR_CONTROLS, cookieBar, PHONE_USE } 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): + * (g) Menu do celular (390x844, com toque: `isMobile` e `hasTouch`, os toques são `tap()`), numa visita nova (a barra de + * cookies está aberta, fixa na base da tela, em qualquer servidor): * - 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 e na barra de cookies com o menu aberto, nos dois sentidos, e a volta passa pelos @@ -15,7 +16,7 @@ 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"]; +const BAR_CONTROLS = COOKIE_BAR_CONTROLS; /** `run` aparece em `sequence`, em posições seguidas? */ function hasRun(sequence: string[], run: string[]) { @@ -24,7 +25,7 @@ function hasRun(sequence: string[], run: string[]) { ); } -test.use({ viewport: PHONE }); +test.use(PHONE_USE); test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "load" }); @@ -39,23 +40,23 @@ 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. + * Espera o cabeçalho responder ao toque (a hidratação terminou), só pelo que o usuário vê: abre o menu pelo botão, + * repetindo o toque 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 closeMenuButton(page).tap(); 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 + // repete o toque enquanto o botão ainda for "Abrir menu": uma recarga do servidor de desenvolvimento (HMR) no meio + // do teste, ou um toque antes da hidratação, não deixa o menu aberto await expect(async () => { if ((await openMenuButton(page).count()) > 0) - await openMenuButton(page).click({ timeout: 2_000 }); + await openMenuButton(page).tap({ timeout: 2_000 }); await expect(closeMenuButton(page)).toHaveAttribute( "aria-expanded", "true", @@ -65,52 +66,6 @@ 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, }) => { @@ -136,6 +91,9 @@ test("o botão de contato do menu não fica sob a barra de cookies", async ({ const barBox = await bar.boundingBox(); if (!box || !barBox) throw new Error("sem caixa para o botão ou a barra"); + // um toque no botão chegaria a ele (a checagem de "recebe o toque" do Playwright, sem navegar) + await contact.tap({ trial: true }); + // o botão termina acima do topo da barra... expect( box.y + box.height, @@ -259,7 +217,7 @@ test("Esc fecha o menu e devolve o foco ao botão do menu", async ({ page }) => test("navegar por um link do menu fecha o menu", async ({ page }) => { await openMenu(page); - await mobileNav(page).getByRole("link", { name: "Cases" }).click(); + await mobileNav(page).getByRole("link", { name: "Cases" }).tap(); await page.waitForURL("**/cases"); await expect(mobileNav(page)).toHaveCount(0); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index 64a821a..e73d85c 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -1,14 +1,18 @@ import { expect, test } from "@playwright/test"; -import { DESKTOP } from "./helpers"; +import { DESKTOP_USE, PHONE_USE } from "./helpers"; /** * (d) Clicar em "Cases" no menu e depois num case navega sem recarregar o documento (um marcador em `window` * sobrevive) e cada troca de página dispara uma startViewTransition; a carga inicial, nenhuma. + * (k) De uma página rolada para outra (/servicos -> /servicos/criacao-de-sites, /cases -> um case), a página nova abre em + * scrollY === 0, sem animação: a cada quadro, a partir do clique, nunca se vê a página nova numa altura diferente de + * 0 (o `data-scroll-behavior` do faz o Next desligar a rolagem suave só durante a troca de rota). No + * computador com clique, no celular (`isMobile`, `hasTouch`) com toque. */ type Probe = { __marker?: string; __viewTransitions?: number }; -test.use({ viewport: DESKTOP }); +test.use(DESKTOP_USE); test("menu Cases e depois um case: sem recarregar o documento, uma transição de página por navegação", async ({ page, @@ -61,3 +65,85 @@ test("menu Cases e depois um case: sem recarregar o documento, uma transição d "o documento foi recarregado (o marcador sumiu)", ).toBe("mesmo-documento"); }); + +const SCROLLED_NAVIGATIONS = [ + { + from: "/servicos", + link: "a[href='/servicos/criacao-de-sites']", + to: /\/servicos\/criacao-de-sites$/, + }, + { from: "/cases", link: "a[href^='/cases/']", to: /\/cases\/[^/]+$/ }, +] as const; + +const DEVICES = [ + { name: "computador, clique", use: DESKTOP_USE, tap: false }, + { name: "celular, toque", use: PHONE_USE, tap: true }, +] as const; + +for (const { name, use, tap } of DEVICES) { + test.describe(`página nova abre no topo, ${name}`, () => { + test.use(use); + + for (const { from, link, to } of SCROLLED_NAVIGATIONS) { + test(`${from} rolado -> próxima página: scrollY 0 em todos os quadros, sem deslizar`, async ({ + page, + }) => { + await page.goto(from, { waitUntil: "load" }); + await page.waitForTimeout(1500); + + const target = page.locator(link).first(); + await target.scrollIntoViewIfNeeded(); + await page.evaluate(() => { + const maxScroll = document.documentElement.scrollHeight - innerHeight; + window.scrollTo({ + top: Math.min(1200, maxScroll), + behavior: "instant", + }); + }); + await page.waitForTimeout(300); + const before = await page.evaluate(() => Math.round(scrollY)); + expect(before, `${from}: a página começa rolada`).toBeGreaterThan(300); + await expect(target).toBeVisible(); + + // a cada quadro desenhado, o caminho e a rolagem que a pessoa vê, até a nova página assentar + await page.evaluate(() => { + const frames: Array<{ path: string; y: number }> = []; + (window as unknown as { __frames: typeof frames }).__frames = frames; + const startedAt = performance.now(); + const sample = () => { + frames.push({ path: location.pathname, y: Math.round(scrollY) }); + if (performance.now() - startedAt < 4_000) + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }); + if (tap) await target.tap(); + else await target.click(); + await page.waitForURL(to); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.waitForTimeout(4_200); + + const { frames, finalY, path } = await page.evaluate(() => ({ + frames: ( + window as unknown as { + __frames: Array<{ path: string; y: number }>; + } + ).__frames, + finalY: Math.round(scrollY), + path: location.pathname, + })); + const onNewPage = frames.filter((frame) => frame.path === path); + expect( + onNewPage.length, + "quadros vistos na página nova", + ).toBeGreaterThan(5); + const notAtTop = onNewPage.filter((frame) => frame.y !== 0); + expect( + notAtTop.slice(0, 8), + `a página nova (${path}) foi vista fora do topo em ${notAtTop.length} de ${onNewPage.length} quadros (antes: ${before}px)`, + ).toEqual([]); + expect(finalY, "rolagem final da página nova").toBe(0); + }); + } + }); +} diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts index 67ac09a..145e1af 100644 --- a/e2e/routes.spec.ts +++ b/e2e/routes.spec.ts @@ -1,10 +1,10 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; -import { DESKTOP, isDevServer, PHONE, sitemapPaths } from "./helpers"; +import { DESKTOP_USE, isDevServer, PHONE_USE, 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). + * (1440x900) e no celular (390x844, `isMobile` e `hasTouch`). * (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. * @@ -19,13 +19,13 @@ const DEV_ONLY_KEY_WARNING = /Each child in a list should have a unique "key" prop/; const VIEWPORTS = [ - { name: "computador 1440x900", size: DESKTOP }, - { name: "celular 390x844", size: PHONE }, + { name: "computador 1440x900", use: DESKTOP_USE }, + { name: "celular 390x844 (isMobile, com toque)", use: PHONE_USE }, ] as const; -for (const { name, size } of VIEWPORTS) { +for (const { name, use } of VIEWPORTS) { test.describe(`rotas do sitemap, ${name}`, () => { - test.use({ viewport: size }); + test.use(use); test("respondem 200, sem erro de console e sem rolagem horizontal", async ({ page, @@ -84,9 +84,9 @@ for (const { name, size } of VIEWPORTS) { }); } -for (const { name, size } of VIEWPORTS) { +for (const { name, use } of VIEWPORTS) { test.describe(`acessibilidade (axe), ${name}`, () => { - test.use({ viewport: size }); + test.use(use); test("nenhuma violação serious ou critical em nenhuma rota do sitemap", async ({ page, diff --git a/e2e/scroll.spec.ts b/e2e/scroll.spec.ts index bf93488..a43c004 100644 --- a/e2e/scroll.spec.ts +++ b/e2e/scroll.spec.ts @@ -1,5 +1,5 @@ import { expect, type Page, test } from "@playwright/test"; -import { DESKTOP, PHONE } from "./helpers"; +import { DESKTOP_USE, PHONE_USE } from "./helpers"; /** * (f) A home não tem trecho vazio, no computador (1440x900) e no celular (390x844): amostrada a cada ¼ de tela de @@ -14,8 +14,8 @@ import { DESKTOP, PHONE } from "./helpers"; const STEP = 1 / 4; const VIEWPORTS = [ - { name: "computador 1440x900", size: DESKTOP }, - { name: "celular 390x844", size: PHONE }, + { name: "computador 1440x900", use: DESKTOP_USE }, + { name: "celular 390x844 (isMobile, com toque)", use: PHONE_USE }, ] as const; interface Sample { @@ -92,9 +92,9 @@ async function sampleHome(page: Page) { return { samples, step, maxScroll, viewportHeight }; } -for (const { name, size } of VIEWPORTS) { +for (const { name, use } of VIEWPORTS) { test.describe(`rolagem da home, ${name}`, () => { - test.use({ viewport: size }); + test.use(use); test("amostrada a cada ¼ de tela, nenhuma posição fica sem texto visível", async ({ page, diff --git a/e2e/sev-poster.spec.ts b/e2e/sev-poster.spec.ts index 6fd9950..087cfde 100644 --- a/e2e/sev-poster.spec.ts +++ b/e2e/sev-poster.spec.ts @@ -1,5 +1,5 @@ import { expect, type Page, test } from "@playwright/test"; -import { DESKTOP, PHONE } from "./helpers"; +import { DESKTOP_USE, PHONE_USE } 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 @@ -12,8 +12,8 @@ const SEV_ASSETS = "/videos/sev-modaintima/"; const MAX_SEV_POSTERS = 2; const PAGES = ["/servicos", "/cases"] as const; const VIEWPORTS = [ - { name: "computador 1440x900", size: DESKTOP }, - { name: "celular 390x844", size: PHONE }, + { name: "computador 1440x900", use: DESKTOP_USE }, + { name: "celular 390x844 (isMobile, com toque)", use: PHONE_USE }, ] as const; /** Elementos que mostram um arquivo de /videos/sev-modaintima/, descritos em texto. */ @@ -46,9 +46,9 @@ function sevPosters(page: Page) { }, SEV_ASSETS); } -for (const { name, size } of VIEWPORTS) { +for (const { name, use } of VIEWPORTS) { test.describe(`pôster da SEV, ${name}`, () => { - test.use({ viewport: size }); + test.use(use); // o detector enxerga o pôster onde ele certamente está (o hero da home e o próprio case): sem isso, "0 imagens" // poderia ser só um seletor que não acha nada