chore: testes de ponta a ponta no CI e limpeza do repositório
Job e2e separado no CI; testes de teclado, rolagem, sem JS, axe no celular e menu do celular; limites de metadados derivados do sitemap; sai @react-three/fiber e @react-three/drei; pastas de ferramentas (.augment, .kiro, .claude/settings.local.json) fora do Git.
This commit is contained in:
1 parent
6ac87824f3
commit
1a60795107
57 files changed
+625
-12090
No files matched your search
+103
-37
@@ -1,62 +1,128 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, type Page, 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.
|
||||
* (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é 40 toques
|
||||
* (a vitrine de projetos é uma parada só, navegada com as setas).
|
||||
* Os testes continuam apertando Tab depois do limite só para dizer, na falha, quantos toques faltavam.
|
||||
*/
|
||||
|
||||
const MAX_TABS = 30;
|
||||
const GIVE_UP_AFTER = 120;
|
||||
const CTA_LABEL = "Falar sobre meu projeto";
|
||||
const SHORTCUT_MAX_TABS_TO_CTA = 20;
|
||||
const SHORTCUT_MAX_TABS_AFTER_ENTER = 4;
|
||||
const LINEAR_MAX_TABS = 40;
|
||||
const LINEAR_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,
|
||||
}) => {
|
||||
/** 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) {
|
||||
return page.evaluate((ctaLabel) => {
|
||||
const element = document.activeElement as HTMLElement | null;
|
||||
if (!element || element === document.body)
|
||||
return { isCta: false, 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 {
|
||||
isCta:
|
||||
Boolean(element.closest("header")) &&
|
||||
(element.textContent ?? "").trim() === ctaLabel,
|
||||
isName:
|
||||
element.getAttribute("name") === "name" &&
|
||||
Boolean(element.closest("#contact")),
|
||||
label: `${element.tagName.toLowerCase()} "${label}"`,
|
||||
};
|
||||
}, CTA_LABEL);
|
||||
}
|
||||
|
||||
/** Visita nova: foco no documento, topo da página. */
|
||||
async function openHomeAsNewVisitor(page: 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" }));
|
||||
}
|
||||
|
||||
/** Aperta Tab até o foco satisfazer `stop`, no máximo `giveUpAfter` vezes. `reached` é 0 se nunca satisfez. */
|
||||
async function tabUntil(
|
||||
page: Page,
|
||||
stop: (where: Awaited<ReturnType<typeof focusedElement>>) => boolean,
|
||||
giveUpAfter: number,
|
||||
) {
|
||||
const visited: string[] = [];
|
||||
let reached = 0;
|
||||
for (let tab = 1; tab <= GIVE_UP_AFTER; tab++) {
|
||||
for (let tab = 1; tab <= giveUpAfter; 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}"`,
|
||||
};
|
||||
});
|
||||
const where = await focusedElement(page);
|
||||
visited.push(`${tab}. ${where.label}`);
|
||||
if (where.isName) {
|
||||
reached = tab;
|
||||
break;
|
||||
}
|
||||
if (stop(where)) return { reached: tab, visited };
|
||||
}
|
||||
return { reached: 0, visited };
|
||||
}
|
||||
|
||||
test(`atalho: Tab até "${CTA_LABEL}" do cabeçalho, Enter, e o campo de nome em até ${SHORTCUT_MAX_TABS_AFTER_ENTER} Tabs`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomeAsNewVisitor(page);
|
||||
|
||||
const toCta = await tabUntil(
|
||||
page,
|
||||
({ isCta }) => isCta,
|
||||
SHORTCUT_MAX_TABS_TO_CTA,
|
||||
);
|
||||
expect(
|
||||
toCta.reached,
|
||||
`"${CTA_LABEL}" do cabeçalho não recebeu foco em ${SHORTCUT_MAX_TABS_TO_CTA} Tabs; passou por:\n${toCta.visited.join("\n")}`,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForURL(/#contact$/);
|
||||
// a rolagem até o contato e o foco da âncora assentam
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const afterEnter = await tabUntil(
|
||||
page,
|
||||
({ isName }) => isName,
|
||||
SHORTCUT_MAX_TABS_AFTER_ENTER + 20,
|
||||
);
|
||||
expect(
|
||||
afterEnter.reached,
|
||||
`o campo de nome não apareceu depois do Enter; passou por:\n${afterEnter.visited.join("\n")}`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
afterEnter.reached,
|
||||
`depois do Enter, o campo de nome só foi alcançado no Tab ${afterEnter.reached} (limite ${SHORTCUT_MAX_TABS_AFTER_ENTER}); passou por:\n${afterEnter.visited.join("\n")}`,
|
||||
).toBeLessThanOrEqual(SHORTCUT_MAX_TABS_AFTER_ENTER);
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomeAsNewVisitor(page);
|
||||
|
||||
const { reached, visited } = await tabUntil(
|
||||
page,
|
||||
({ isName }) => isName,
|
||||
LINEAR_GIVE_UP_AFTER,
|
||||
);
|
||||
|
||||
expect(
|
||||
reached,
|
||||
`o campo de nome não apareceu nem em ${GIVE_UP_AFTER} Tabs`,
|
||||
`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 ${MAX_TABS}); passou por:\n${visited.join("\n")}`,
|
||||
).toBeLessThanOrEqual(MAX_TABS);
|
||||
`o campo de nome só foi alcançado no Tab ${reached} (limite ${LINEAR_MAX_TABS}); passou por:\n${visited.join("\n")}`,
|
||||
).toBeLessThanOrEqual(LINEAR_MAX_TABS);
|
||||
await expect(page.locator("#contact input[name='name']")).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { 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 com o menu aberto, nos dois sentidos;
|
||||
* - 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";
|
||||
|
||||
test.use({ viewport: PHONE });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "load" });
|
||||
// o botão do menu só responde ao clique depois da hidratação: o React põe nele as props do componente
|
||||
await page.waitForFunction(() => {
|
||||
const button = document.querySelector("header button");
|
||||
return (
|
||||
button !== null &&
|
||||
Object.keys(button).some((key) => key.startsWith("__reactProps"))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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 = page.getByRole("dialog", { name: "Cookies e privacidade" });
|
||||
// A barra só é renderizada no build de produção (GoogleAnalytics.tsx). Num servidor de desenvolvimento entra no
|
||||
// lugar dela uma barra de mesmo contrato (fixa na base, 96 px, --cookie-bar-height publicada na raiz), para o
|
||||
// teste conferir o lado do cabeçalho. No CI (build de produção) a ausência da barra real reprova.
|
||||
if ((await bar.count()) === 0 && !process.env.CI) {
|
||||
await page.evaluate(() => {
|
||||
const stand = document.createElement("section");
|
||||
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";
|
||||
stand.textContent = "Usamos métricas de navegação do Google";
|
||||
document.body.append(stand);
|
||||
document.documentElement.style.setProperty("--cookie-bar-height", "96px");
|
||||
});
|
||||
}
|
||||
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", async ({
|
||||
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 {
|
||||
insideHeader: Boolean(element?.closest("header")),
|
||||
label: `${element?.tagName.toLowerCase()} "${label}"`,
|
||||
};
|
||||
});
|
||||
|
||||
for (const key of ["Tab", "Shift+Tab"]) {
|
||||
const visited = new Set<string>();
|
||||
// mais toques do que controles no cabeçalho (logo, botão, 5 links, contato): dá a volta inteira, mais de uma vez
|
||||
for (let press = 1; press <= 20; press++) {
|
||||
await page.keyboard.press(key);
|
||||
const { insideHeader, label } = await whereIsFocus();
|
||||
expect(
|
||||
insideHeader,
|
||||
`${key} nº ${press} levou o foco para fora do cabeçalho: ${label}`,
|
||||
).toBe(true);
|
||||
visited.add(label);
|
||||
}
|
||||
expect(
|
||||
visited.size,
|
||||
`${key} percorreu ${visited.size} controles: ${[...visited].join("; ")}`,
|
||||
).toBeGreaterThanOrEqual(8);
|
||||
}
|
||||
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();
|
||||
});
|
||||
+46
-10
@@ -1,11 +1,44 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, type Locator, 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. */
|
||||
/**
|
||||
* (e) Com JavaScript desligado, a home mostra o texto do contato e o nome dos clientes: no HTML, visíveis e com
|
||||
* opacidade calculada acima de 0,5 (um texto "visível" mas a 0,1 de opacidade, à espera de uma animação que não
|
||||
* roda sem JavaScript, não serve a quem lê).
|
||||
*/
|
||||
|
||||
const MIN_OPACITY = 0.5;
|
||||
|
||||
test.use({ viewport: DESKTOP, javaScriptEnabled: false });
|
||||
|
||||
test("sem JavaScript a home mostra o contato e os nomes dos 4 clientes", async ({
|
||||
/** Opacidade efetiva: o produto das opacidades calculadas do elemento e de todos os seus ancestrais. */
|
||||
function effectiveOpacity(locator: Locator) {
|
||||
return locator.evaluate((element) => {
|
||||
let opacity = 1;
|
||||
for (let node: Element | null = element; node; node = node.parentElement)
|
||||
opacity *= Number.parseFloat(getComputedStyle(node).opacity);
|
||||
return opacity;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Leva o texto ao meio da janela, como quem lê rolando (a passagem entre seções é um fade ligado à rolagem, em CSS
|
||||
* puro: antes de a seção chegar à janela o conteúdo espera a 0, e isso não depende de JavaScript) e espera a
|
||||
* opacidade passar de MIN_OPACITY: as animações de entrada em CSS levam um instante para terminar.
|
||||
*/
|
||||
async function expectReadable(locator: Locator, what: string) {
|
||||
await locator.evaluate((element) =>
|
||||
element.scrollIntoView({ block: "center", behavior: "instant" }),
|
||||
);
|
||||
await expect
|
||||
.poll(() => effectiveOpacity(locator), {
|
||||
message: `opacidade de ${what}`,
|
||||
timeout: 5_000,
|
||||
})
|
||||
.toBeGreaterThan(MIN_OPACITY);
|
||||
}
|
||||
|
||||
test("sem JavaScript a home mostra o contato e os nomes dos 4 clientes, legíveis", async ({
|
||||
page,
|
||||
}) => {
|
||||
const clients = clientProjectNames();
|
||||
@@ -14,14 +47,17 @@ test("sem JavaScript a home mostra o contato e os nomes dos 4 clientes", async (
|
||||
const response = await page.goto("/", { waitUntil: "load" });
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: CONTACT_HEADING }),
|
||||
).toBeVisible();
|
||||
const heading = page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: CONTACT_HEADING,
|
||||
});
|
||||
await expect(heading).toBeVisible();
|
||||
await expectReadable(heading, "o texto do contato");
|
||||
|
||||
for (const name of clients) {
|
||||
await expect(
|
||||
page.getByText(name, { exact: false }).first(),
|
||||
`cliente "${name}"`,
|
||||
).toBeVisible();
|
||||
const text = page.getByText(name, { exact: false }).first();
|
||||
await expect(text, `cliente "${name}"`).toBeVisible();
|
||||
await expectReadable(text, `o cliente "${name}"`);
|
||||
}
|
||||
// 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();
|
||||
|
||||
+27
-23
@@ -5,7 +5,7 @@ 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.
|
||||
* (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.
|
||||
*/
|
||||
|
||||
@@ -64,28 +64,32 @@ for (const { name, size } of VIEWPORTS) {
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("acessibilidade (axe), computador", () => {
|
||||
test.use({ viewport: DESKTOP });
|
||||
for (const { name, size } of VIEWPORTS) {
|
||||
test.describe(`acessibilidade (axe), ${name}`, () => {
|
||||
test.use({ viewport: size });
|
||||
|
||||
test("nenhuma violação serious ou critical em nenhuma rota do sitemap", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const paths = await sitemapPaths(request);
|
||||
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([]);
|
||||
});
|
||||
}
|
||||
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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
+113
-58
@@ -1,72 +1,127 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, type Page, 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).
|
||||
* (f) No computador, a home não tem trecho vazio: amostrada a cada ¼ de tela de rolagem, nenhuma sequência de
|
||||
* amostras sem texto cobre ½ tela ou mais de rolagem (3 amostras seguidas vazias ou mais). Uma amostra vazia
|
||||
* isolada, ou duas seguidas (menos de ½ tela), é o instante de uma troca de cena e não reprova.
|
||||
* Uma amostra é "vazia" quando não há nenhum 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 amostras e esconderia uma tela vazia).
|
||||
*/
|
||||
|
||||
/** Passo da amostragem, em fração da altura da janela. */
|
||||
const STEP = 1 / 4;
|
||||
/** Trecho vazio (em fração da altura da janela) a partir do qual o teste reprova. */
|
||||
const MAX_EMPTY_SPAN = 1 / 2;
|
||||
|
||||
test.use({ viewport: DESKTOP });
|
||||
|
||||
test("nenhuma parada a cada 10 % da home tem tela vazia", async ({ page }) => {
|
||||
interface Sample {
|
||||
top: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Quantos trechos de texto visíveis há na janela agora. */
|
||||
function countVisibleText(page: Page) {
|
||||
return 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 isShown = (element: Element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return (
|
||||
style.visibility === "visible" &&
|
||||
style.display !== "none" &&
|
||||
effectiveOpacity(element) >= 0.2
|
||||
);
|
||||
};
|
||||
const isInsideWindow = (rect: DOMRect) =>
|
||||
rect.width > 1 &&
|
||||
rect.height > 1 &&
|
||||
rect.bottom > 0 &&
|
||||
rect.top < innerHeight &&
|
||||
rect.right > 0 &&
|
||||
rect.left < innerWidth;
|
||||
const isVisibleText = (node: Node) => {
|
||||
const parent = node.parentElement;
|
||||
if ((node.textContent?.trim().length ?? 0) < 3 || !parent) return false;
|
||||
if (parent.closest(ignored) || !isShown(parent)) return false;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
return isInsideWindow(range.getBoundingClientRect());
|
||||
};
|
||||
|
||||
let visible = 0;
|
||||
const walker = document.createTreeWalker(
|
||||
document.body,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode())
|
||||
if (isVisibleText(node)) visible += 1;
|
||||
return visible;
|
||||
});
|
||||
}
|
||||
|
||||
/** Rola a home de ¼ em ¼ de tela, do topo ao fim, e conta o texto visível em cada parada. */
|
||||
async function sampleHome(page: Page) {
|
||||
const { maxScroll, viewportHeight } = await page.evaluate(() => ({
|
||||
maxScroll: document.documentElement.scrollHeight - innerHeight,
|
||||
viewportHeight: innerHeight,
|
||||
}));
|
||||
const step = Math.round(viewportHeight * STEP);
|
||||
const positions: number[] = [];
|
||||
for (let top = 0; top < maxScroll; top += step) positions.push(top);
|
||||
positions.push(Math.max(maxScroll, 0));
|
||||
|
||||
const samples: Sample[] = [];
|
||||
for (const top of positions) {
|
||||
await page.evaluate((target) => {
|
||||
window.scrollTo({ top: target, behavior: "instant" });
|
||||
}, top);
|
||||
// a rolagem suave, as animações ligadas à rolagem e as imagens preguiçosas assentam
|
||||
await page.waitForTimeout(600);
|
||||
samples.push({ top, count: await countVisibleText(page) });
|
||||
}
|
||||
return { samples, step, maxScroll, viewportHeight };
|
||||
}
|
||||
|
||||
/** Sequências de amostras vazias seguidas cuja rolagem (da primeira à última da sequência) chega a `minSpan`. */
|
||||
function emptyRuns(samples: Sample[], minSpan: number) {
|
||||
const runs: string[] = [];
|
||||
let start = -1;
|
||||
for (const [index, { count }] of samples.entries()) {
|
||||
if (count === 0 && start < 0) start = index;
|
||||
const endsRun = count > 0 || index === samples.length - 1;
|
||||
if (start >= 0 && endsRun) {
|
||||
const last = count === 0 ? index : index - 1;
|
||||
const span = samples[last].top - samples[start].top;
|
||||
if (span >= minSpan)
|
||||
runs.push(`${samples[start].top}px a ${samples[last].top}px`);
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
test("a home, amostrada a cada ¼ de tela, não tem janela de ½ tela ou mais sem texto visível", 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);
|
||||
}
|
||||
const { samples, step, maxScroll, viewportHeight } = await sampleHome(page);
|
||||
const gaps = emptyRuns(samples, viewportHeight * MAX_EMPTY_SPAN);
|
||||
|
||||
const log = samples
|
||||
.map(({ top, count }) => `${top}px: ${count} trecho(s) de texto`)
|
||||
.join("\n");
|
||||
expect(
|
||||
empty,
|
||||
`paradas com tela vazia (nenhum texto visível):\n${stops.join("\n")}`,
|
||||
gaps,
|
||||
`janelas de ½ tela ou mais sem texto visível (amostras a cada ${step}px, página de ${maxScroll}px):\n${log}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
Reference in new issue
Block a user