chore: dependências e teste de ponta a ponta

Saem gsap, motion, clsx e tailwind-merge; entram server-only, @playwright/test e @axe-core/playwright. Suíte e2e (rotas, axe, teclado, navegação, sem JS, rolagem) rodável com pnpm test:e2e contra BASE_URL.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 14:15:50 -03:00
1 parent 318a764190
commit 7b633f0f4a
12 files changed
+459 -94

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { APIRequestContext } 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) {
const response = await request.get("/sitemap.xml");
if (response.status() !== 200)
throw new Error(`/sitemap.xml respondeu ${response.status()}`);
const xml = await response.text();
const paths = [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map(
([, loc]) => {
const { pathname } = new URL(loc);
return pathname === "" ? "/" : pathname;
},
);
return [...new Set(paths)];
}
/** Nomes dos projetos de cliente, como a home os lista (public/portfolio-data.json, kind "cliente"). */
export function clientProjectNames() {
const data = JSON.parse(
readFileSync(
join(__dirname, "..", "public", "portfolio-data.json"),
"utf8",
),
) as { projects: Array<{ title: string; kind?: string }> };
return data.projects
.filter((project) => project.kind === "cliente")
.map((project) => project.title);
}
/** Texto do cabeçalho da seção de contato da home. */
export const CONTACT_HEADING =
"Vamos conversar sobre o que sua empresa precisa.";
export const DESKTOP = { width: 1440, height: 900 } as const;
export const PHONE = { width: 390, height: 844 } as const;
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
import { DESKTOP } from "./helpers";
/**
* (c) Do topo da home, o Tab chega ao campo de nome do contato em no máximo 30 toques.
* O teste continua apertando Tab (até 120) depois do limite só para dizer, na falha, quantos toques faltavam.
*/
const MAX_TABS = 30;
const GIVE_UP_AFTER = 120;
test.use({ viewport: DESKTOP });
test(`Tab do topo da home chega ao campo de nome do contato em até ${MAX_TABS} toques`, async ({
page,
}) => {
await page.goto("/", { waitUntil: "load" });
await page.waitForTimeout(800);
// o foco começa no documento, como numa visita nova
await page.evaluate(() => (document.activeElement as HTMLElement)?.blur());
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" }));
const visited: string[] = [];
let reached = 0;
for (let tab = 1; tab <= GIVE_UP_AFTER; tab++) {
await page.keyboard.press("Tab");
const where = await page.evaluate(() => {
const element = document.activeElement as HTMLElement | null;
if (!element) return { isName: false, label: "(nada)" };
const label = (
element.getAttribute("aria-label") ||
element.textContent ||
element.getAttribute("name") ||
element.tagName
)
.trim()
.replace(/\s+/g, " ")
.slice(0, 40);
return {
isName:
element.getAttribute("name") === "name" &&
Boolean(element.closest("#contact")),
label: `${element.tagName.toLowerCase()} "${label}"`,
};
});
visited.push(`${tab}. ${where.label}`);
if (where.isName) {
reached = tab;
break;
}
}
expect(
reached,
`o campo de nome não apareceu nem em ${GIVE_UP_AFTER} Tabs`,
).toBeGreaterThan(0);
expect(
reached,
`o campo de nome só foi alcançado no Tab ${reached} (limite ${MAX_TABS}); passou por:\n${visited.join("\n")}`,
).toBeLessThanOrEqual(MAX_TABS);
await expect(page.locator("#contact input[name='name']")).toBeFocused();
});
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test";
import { DESKTOP } 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.
*/
type Probe = { __marker?: string; __viewTransitions?: number };
test.use({ viewport: DESKTOP });
test("menu Cases e depois um case: sem recarregar o documento, uma transição de página por navegação", async ({
page,
}) => {
// conta as chamadas de document.startViewTransition, desde antes do primeiro script da página
await page.addInitScript(() => {
const probe = window as unknown as Probe;
probe.__viewTransitions = 0;
const original = document.startViewTransition?.bind(document);
if (!original) return;
document.startViewTransition = ((...args: Parameters<typeof original>) => {
(probe.__viewTransitions as number) += 1;
return original(...args);
}) as typeof document.startViewTransition;
});
const transitions = () =>
page.evaluate(() => (window as unknown as Probe).__viewTransitions);
await page.goto("/", { waitUntil: "load" });
await page.waitForTimeout(1500);
await page.evaluate(() => {
(window as unknown as Probe).__marker = "mesmo-documento";
});
expect(
await page.evaluate(() => "startViewTransition" in document),
"o navegador precisa ter startViewTransition",
).toBe(true);
expect(await transitions(), "carga inicial").toBe(0);
await page
.getByRole("navigation", { name: "Navegação principal" })
.getByRole("link", { name: "Cases" })
.click();
await page.waitForURL("**/cases");
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await page.waitForTimeout(500);
expect(await transitions(), "depois de Cases").toBe(1);
await page.getByRole("link", { name: "Ver o case" }).first().click();
await page.waitForURL(/\/cases\/[^/]+$/);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await page.waitForTimeout(500);
expect(await transitions(), "depois de abrir um case").toBe(2);
expect(
await page.evaluate(() => (window as unknown as Probe).__marker),
"o documento foi recarregado (o marcador sumiu)",
).toBe("mesmo-documento");
});
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "@playwright/test";
import { CONTACT_HEADING, clientProjectNames, DESKTOP } from "./helpers";
/** (e) Com JavaScript desligado, a home mostra o texto do contato e o nome dos clientes. */
test.use({ viewport: DESKTOP, javaScriptEnabled: false });
test("sem JavaScript a home mostra o contato e os nomes dos 4 clientes", async ({
page,
}) => {
const clients = clientProjectNames();
expect(clients, "clientes em public/portfolio-data.json").toHaveLength(4);
const response = await page.goto("/", { waitUntil: "load" });
expect(response?.status()).toBe(200);
await expect(
page.getByRole("heading", { level: 2, name: CONTACT_HEADING }),
).toBeVisible();
for (const name of clients) {
await expect(
page.getByText(name, { exact: false }).first(),
`cliente "${name}"`,
).toBeVisible();
}
// o texto está no HTML entregue, não só na tela: vale também para quem lê sem renderizar
const html = await (await page.request.get("/")).text();
expect(html).toContain(CONTACT_HEADING);
for (const name of clients) expect(html).toContain(name);
});
+91
View File
@@ -0,0 +1,91 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { DESKTOP, 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.
* As rotas vêm do próprio sitemap: uma página nova entra no teste sozinha.
*/
const VIEWPORTS = [
{ name: "computador 1440x900", size: DESKTOP },
{ name: "celular 390x844", size: PHONE },
] as const;
for (const { name, size } of VIEWPORTS) {
test.describe(`rotas do sitemap, ${name}`, () => {
test.use({ viewport: size });
test("respondem 200, sem erro de console e sem rolagem horizontal", async ({
page,
request,
}) => {
const paths = await sitemapPaths(request);
expect(paths.length, "rotas no sitemap").toBeGreaterThan(10);
for (const path of paths) {
await test.step(path, async () => {
const problems: string[] = [];
const onConsole = (
message: import("@playwright/test").ConsoleMessage,
) => {
if (message.type() === "error")
problems.push(`console.error: ${message.text()}`);
};
const onPageError = (error: Error) =>
problems.push(`erro não tratado: ${error.message}`);
page.on("console", onConsole);
page.on("pageerror", onPageError);
const response = await page.goto(path, { waitUntil: "load" });
// dá tempo de a hidratação e das primeiras animações acontecerem (e de errarem)
await page.waitForTimeout(800);
page.off("console", onConsole);
page.off("pageerror", onPageError);
expect.soft(response?.status(), `${path}: status`).toBe(200);
expect.soft(problems, `${path}: console`).toEqual([]);
const overflow = await page.evaluate(
() =>
Math.max(
document.documentElement.scrollWidth,
document.body.scrollWidth,
) - document.documentElement.clientWidth,
);
expect
.soft(overflow, `${path}: px de rolagem horizontal`)
.toBeLessThanOrEqual(0);
});
}
});
});
}
test.describe("acessibilidade (axe), computador", () => {
test.use({ viewport: DESKTOP });
test("nenhuma violação serious ou critical em nenhuma rota do sitemap", async ({
page,
request,
}) => {
const paths = await sitemapPaths(request);
for (const path of paths) {
await test.step(path, async () => {
await page.goto(path, { waitUntil: "load" });
await page.waitForTimeout(800);
const { violations } = await new AxeBuilder({ page }).analyze();
const serious = violations
.filter(({ impact }) => impact === "serious" || impact === "critical")
.map(
({ id, impact, nodes }) =>
`${id} (${impact}): ${nodes.length} elemento(s), por exemplo ${nodes[0]?.target.join(" ")}`,
);
expect.soft(serious, `${path}: axe`).toEqual([]);
});
}
});
});
+72
View File
@@ -0,0 +1,72 @@
import { expect, test } from "@playwright/test";
import { DESKTOP } from "./helpers";
/**
* (f) No computador, nenhuma das paradas a cada 10 % da rolagem da home tem tela vazia: sempre há ao menos um
* 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 paradas e esconderia uma tela vazia).
*/
test.use({ viewport: DESKTOP });
test("nenhuma parada a cada 10 % da home tem tela vazia", async ({ page }) => {
await page.goto("/", { waitUntil: "load" });
await page.waitForTimeout(1500);
const empty: string[] = [];
const stops: string[] = [];
for (let step = 0; step <= 10; step++) {
await page.evaluate((fraction) => {
const max = document.documentElement.scrollHeight - innerHeight;
window.scrollTo({ top: Math.round(max * fraction), behavior: "instant" });
}, step / 10);
// a rolagem suave, as animações ligadas à rolagem e as imagens preguiçosas assentam
await page.waitForTimeout(700);
const result = await page.evaluate(() => {
const ignored =
"header, footer [aria-hidden], [aria-hidden='true'], [role='dialog'], .sr-only, .skip-link, script, style, noscript";
const effectiveOpacity = (element: Element | null) => {
let opacity = 1;
for (let node = element; node; node = node.parentElement)
opacity *= Number.parseFloat(getComputedStyle(node).opacity);
return opacity;
};
const visible: string[] = [];
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent?.trim() ?? "";
const parent = node.parentElement;
if (text.length < 3 || !parent || parent.closest(ignored)) continue;
const style = getComputedStyle(parent);
if (style.visibility !== "visible" || style.display === "none")
continue;
if (effectiveOpacity(parent) < 0.2) continue;
const range = document.createRange();
range.selectNodeContents(node);
const rect = range.getBoundingClientRect();
const inside =
rect.width > 1 &&
rect.height > 1 &&
rect.bottom > 0 &&
rect.top < innerHeight &&
rect.right > 0 &&
rect.left < innerWidth;
if (inside) visible.push(text.slice(0, 40));
}
return { count: visible.length, sample: visible.slice(0, 2) };
});
const label = `${step * 10}%`;
stops.push(`${label}: ${result.count} trecho(s) de texto`);
if (result.count === 0) empty.push(label);
}
expect(
empty,
`paradas com tela vazia (nenhum texto visível):\n${stops.join("\n")}`,
).toEqual([]);
});