import { expect, type Page, test } from "@playwright/test"; 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 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" }); await waitUntilInteractive(page); }); const openMenuButton = (page: Page) => page.getByRole("button", { name: "Abrir menu" }); const closeMenuButton = (page: Page) => page.getByRole("button", { name: "Fechar menu" }); 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 await expect(async () => { if ((await openMenuButton(page).count()) > 0) await openMenuButton(page).click({ timeout: 2_000 }); await expect(closeMenuButton(page)).toHaveAttribute( "aria-expanded", "true", { timeout: 2_000 }, ); }).toPass({ timeout: 20_000 }); 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, }) => { await openMenu(page); const nav = mobileNav(page); for (const label of ["Cases", "Serviços", "Como trabalho", "Sobre", "IA"]) await expect( nav.getByRole("link", { name: label, exact: true }), ).toBeVisible(); await expect(nav.getByRole("link", { name: CTA_LABEL })).toBeVisible(); }); test("o botão de contato do menu não fica sob a barra de cookies", async ({ page, }) => { const bar = await cookieBar(page); await openMenu(page); await expect(bar).toBeVisible(); const contact = mobileNav(page).getByRole("link", { name: CTA_LABEL }); await expect(contact).toBeVisible(); const box = await contact.boundingBox(); const barBox = await bar.boundingBox(); if (!box || !barBox) throw new Error("sem caixa para o botão ou a barra"); // o botão termina acima do topo da barra... expect( box.y + box.height, `base do botão (${box.y + box.height}px) x topo da barra de cookies (${barBox.y}px)`, ).toBeLessThanOrEqual(barBox.y); // ...e, em cada ponto testado, o elemento que o usuário atingiria é o próprio botão const points = [ { x: box.x + box.width / 2, y: box.y + box.height / 2 }, { x: box.x + box.width / 2, y: box.y + box.height - 2 }, { x: box.x + 4, y: box.y + box.height - 2 }, { x: box.x + box.width - 4, y: box.y + box.height - 2 }, ]; for (const { x, y } of points) { const hit = await contact.evaluate( (element, point) => { const atPoint = document.elementFromPoint(point.x, point.y); return { isButton: atPoint === element || element.contains(atPoint), found: atPoint ? `${atPoint.tagName.toLowerCase()} "${(atPoint.textContent ?? "").trim().slice(0, 30)}"` : "(nada)", }; }, { x, y }, ); expect( hit.isButton, `em (${Math.round(x)}, ${Math.round(y)}) está ${hit.found}, não o botão de contato`, ).toBe(true); } }); 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"); const whereIsFocus = () => page.evaluate(() => { const element = document.activeElement as HTMLElement | null; const label = ( element?.getAttribute("aria-label") || element?.textContent || element?.tagName || "(nada)" ) .trim() .replace(/\s+/g, " ") .slice(0, 40); return { // 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 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 { inside, label } = await whereIsFocus(); expect( inside, `${key} nº ${press} levou o foco para fora do cabeçalho e da barra de cookies: ${label}`, ).toBe(true); sequence.push(label); } const visited = new Set(sequence); const missing = cycle.filter((label) => !visited.has(label)); expect( 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(); }); test("Esc fecha o menu e devolve o foco ao botão do menu", async ({ page }) => { await openMenu(page); // o foco anda para dentro do menu antes do Esc await page.keyboard.press("Tab"); await page.keyboard.press("Tab"); await page.keyboard.press("Escape"); await expect(mobileNav(page)).toHaveCount(0); const button = openMenuButton(page); await expect(button).toHaveAttribute("aria-expanded", "false"); await expect(button).toBeFocused(); // a página volta a responder: nada ficou inerte nem sem rolagem await expect(page.locator("#main-content")).not.toHaveAttribute("inert"); expect( await page.evaluate(() => document.documentElement.style.overflow), "overflow da página depois de fechar", ).not.toBe("hidden"); }); test("navegar por um link do menu fecha o menu", async ({ page }) => { await openMenu(page); await mobileNav(page).getByRole("link", { name: "Cases" }).click(); await page.waitForURL("**/cases"); await expect(mobileNav(page)).toHaveCount(0); await expect(openMenuButton(page)).toHaveAttribute("aria-expanded", "false"); await expect(page.locator("#main-content")).not.toHaveAttribute("inert"); await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); });