Files
portifolio-evandrodevbr/e2e/mobile-menu.spec.ts
evandro c7d09d63d4
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s
feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
Hero e movimento
- cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py):
  marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao
  cursor e, parada no topo, passa sozinha pelos estados com legenda
- três níveis de aparelho (full, lite, static); sem WebGL fica o pôster
- título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
- faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções
- rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa
  compartilhada entre a lista de cases e o case

Ofertas
- atendimento no WhatsApp com IA e nota fiscal para a reforma tributária
  (páginas de serviço, cartões da home, formulário e aviso com data de validade)
- página /ia atualizada, com fontes
- peças 3D em loop nos cartões e topos de página (3d/pecas.py)

Design
- hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta
- um destino por rótulo: "Falar sobre meu projeto" leva ao formulário,
  "Chamar no WhatsApp" abre o WhatsApp
- logo montado em blocos, 404 redesenhado, grades sem célula vazia
- ícones do PWA e manifesto novos

Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
2026-11-10 03:48:25 -03:00

234 lines
8.4 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test";
import { COOKIE_BAR_CONTROLS, cookieBar, PHONE_USE } from "./helpers";
/**
* (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
* 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 = COOKIE_BAR_CONTROLS;
/** `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(PHONE_USE);
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 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).tap();
await expect(openMenuButton(page)).toHaveAttribute("aria-expanded", "false");
await expect(mobileNav(page)).toHaveCount(0);
}
async function openMenu(page: Page) {
// 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).tap({ timeout: 2_000 });
await expect(closeMenuButton(page)).toHaveAttribute(
"aria-expanded",
"true",
{ timeout: 2_000 },
);
}).toPass({ timeout: 20_000 });
await expect(mobileNav(page)).toBeVisible();
}
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",
"Revisão de app com 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");
// 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,
`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",
"Revisão de app com 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" }).tap();
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();
});