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:
1 parent
318a764190
commit
7b633f0f4a
12 files changed
+459
-94
No files matched your search
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
Reference in new issue
Block a user