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.
128 lines
4.7 KiB
TypeScript
128 lines
4.7 KiB
TypeScript
import { expect, type Page, test } from "@playwright/test";
|
|
import { DESKTOP } from "./helpers";
|
|
|
|
/**
|
|
* (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 });
|
|
|
|
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 { 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(
|
|
gaps,
|
|
`janelas de ½ tela ou mais sem texto visível (amostras a cada ${step}px, página de ${maxScroll}px):\n${log}`,
|
|
).toEqual([]);
|
|
});
|