Files
portifolio-evandrodevbr/e2e/jogo.spec.ts
T
evandro 67b32fcab3
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s
feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
Etapa 1 inteira (logica com 100% de cobertura, cena 3D, toque, sons, bancada de desempenho, e2e) e a base da etapa 2 (mapa novo no Tiled, texturas, imagens dos itens, sons conferidos, golpe recortado e medido). Pagina /jogo fora do menu e do sitemap ate a medicao no celular.
2026-11-10 19:37:10 -03:00

580 lines
20 KiB
TypeScript

import AxeBuilder from "@axe-core/playwright";
import {
type Browser,
type BrowserContext,
type BrowserContextOptions,
type CDPSession,
chromium,
expect,
type Locator,
type Page,
test,
} from "@playwright/test";
import { PROBLEMAS, problemaPorId } from "../lib/jogo/problemas";
import { DESKTOP, isDevServer, saveConsent } from "./helpers";
/**
* Ponta a ponta do Expediente dos Mortos (/jogo): CA1, CA4, CA8, CA9, CA11 e CA13 da spec
* specs/2026-11-10-jogo-zumbis-etapa-1.md.
*
* Os casos que desenham 3D abrem um Chromium próprio com os argumentos de GPU da bancada. Sem WebGL (CI sem GPU)
* eles conferem só o aviso "Este navegador não desenha 3D." e anotam que rodaram sem 3D.
* Falha num desses casos deixa a captura `falha-N.png` na pasta do teste em test-results/.
*/
const GPU = [
"--enable-gpu",
"--ignore-gpu-blocklist",
"--use-angle=vulkan",
"--enable-features=Vulkan",
];
// CA9: `gc()` na página e `performance.memory` sem arredondamento
const MEMORIA = ["--js-flags=--expose-gc", "--enable-precise-memory-info"];
const SEM_3D = "Este navegador não desenha 3D.";
// mesma exceção de e2e/routes.spec.ts: aviso que só o React de desenvolvimento emite
const AVISO_DO_DEV = /Each child in a list should have a unique "key" prop/;
const FIM = /^(Problemas resolvidos|Os problemas venceram desta vez)$/;
let navegador: Browser;
const contextos: BrowserContext[] = [];
test.beforeAll(async () => {
navegador = await chromium.launch({
executablePath: process.env.PW_CHROMIUM || undefined,
args: [...GPU, ...MEMORIA],
});
});
test.afterAll(async () => {
await navegador?.close();
});
test.afterEach(async () => {
const info = test.info();
const falhou = info.status !== info.expectedStatus;
const paginas = contextos.flatMap((c) => c.pages());
for (const [i, pagina] of paginas.entries())
if (falhou)
await pagina
.screenshot({ path: info.outputPath(`falha-${i}.png`) })
.catch(() => {});
for (const contexto of contextos.splice(0)) await contexto.close();
});
/** Página no Chromium com GPU, com o consentimento de cookies já salvo para a barra não cobrir o jogo. */
async function abrir(
opcoes: BrowserContextOptions,
baseURL: string | undefined,
): Promise<Page> {
const contexto = await navegador.newContext({ baseURL, ...opcoes });
contextos.push(contexto);
const page = await contexto.newPage();
await saveConsent(page);
return page;
}
/** Sem WebGL: confere o aviso, anota e devolve true para o teste terminar ali. */
async function sem3d(page: Page): Promise<boolean> {
const tem = await page.evaluate(() => {
const contexto = document.createElement("canvas").getContext("webgl2");
contexto?.getExtension("WEBGL_lose_context")?.loseContext();
return contexto !== null;
});
if (tem) return false;
await expect(page.getByText(SEM_3D)).toBeVisible();
test.info().annotations.push({
type: "sem 3D",
description: "WebGL indisponível: conferido só o aviso da página",
});
return true;
}
function vigiarConsole(page: Page, dev: boolean): string[] {
const erros: string[] = [];
page.on("console", (mensagem) => {
if (mensagem.type() !== "error") return;
if (dev && AVISO_DO_DEV.test(mensagem.text())) return;
erros.push(mensagem.text());
});
page.on("pageerror", (erro) => erros.push(erro.message));
return erros;
}
const estado = (page: Page) => page.evaluate(() => window.__jogo);
async function comecarPartida(page: Page, nome: string) {
await page
.locator("label", {
has: page.getByRole("radio", { name: nome, exact: true }),
})
.click();
await page
.getByRole("button", { name: /^(Começar|Jogar mesmo assim)$/ })
.click();
await page.waitForFunction(() => window.__jogo !== undefined, null, {
timeout: 90_000,
});
}
async function dentroDaTela(
alvo: Locator,
tela: { width: number; height: number },
) {
const caixa = await alvo.boundingBox();
expect(caixa, "elemento sem caixa").not.toBeNull();
if (!caixa) return;
const fora = `${JSON.stringify(caixa)} fora de ${tela.width}x${tela.height}`;
expect(caixa.x, fora).toBeGreaterThanOrEqual(0);
expect(caixa.y, fora).toBeGreaterThanOrEqual(0);
expect(caixa.x + caixa.width, fora).toBeLessThanOrEqual(tela.width + 0.5);
expect(caixa.y + caixa.height, fora).toBeLessThanOrEqual(tela.height + 0.5);
}
type Dedo = { id: number; x: number; y: number };
const tocar = (
cdp: CDPSession,
type: "touchStart" | "touchMove" | "touchEnd",
touchPoints: Dedo[],
) => cdp.send("Input.dispatchTouchEvent", { type, touchPoints });
/** O que passou pela tela durante a partida: cartões "Resolvido:" e linhas do feed. Roda dentro da página. */
type Vistos = { resolvidos: string[]; feed: string[] };
declare global {
interface Window {
__vistos?: Vistos;
}
}
function anotarTextos() {
const vistos: Vistos = { resolvidos: [], feed: [] };
window.__vistos = vistos;
const olhar = () => {
for (const p of document.querySelectorAll("p")) {
const texto = p.textContent ?? "";
const nome = texto.startsWith("Resolvido: ") ? texto.slice(11) : "";
if (nome && !vistos.resolvidos.includes(nome))
vistos.resolvidos.push(nome);
}
for (const li of document.querySelectorAll(
'[aria-label="Últimas eliminações"] li',
)) {
const texto = li.textContent ?? "";
if (!vistos.feed.includes(texto)) vistos.feed.push(texto);
}
};
new MutationObserver(olhar).observe(document, {
childList: true,
subtree: true,
characterData: true,
});
}
async function axeSerio(page: Page) {
const { violations } = await new AxeBuilder({ page }).analyze();
return violations
.filter(({ impact }) => impact === "serious" || impact === "critical")
.map(
({ id, impact, nodes }) =>
`${id} (${impact}): ${nodes.length} elemento(s), por exemplo ${nodes[0]?.target.join(" ")}`,
);
}
test("CA1: o piloto joga a partida inteira e o relatório liga cada problema ao serviço", async ({
baseURL,
request,
}) => {
test.setTimeout(300_000);
const dev = await isDevServer(request);
const page = await abrir({ viewport: { width: 960, height: 540 } }, baseURL);
const erros = vigiarConsole(page, dev);
await page.addInitScript(anotarTextos);
await page.goto(
"/jogo?piloto=1&semente=1&problema=site-lento&dificuldade=facil&vel=8",
);
if (await sem3d(page)) return;
const titulo = page.getByRole("heading", { level: 1, name: FIM });
await expect(titulo).toBeVisible({ timeout: 240_000 });
const vistos = await page.evaluate(() => window.__vistos);
if (!vistos) throw new Error("o observador de textos não rodou");
console.log(`CA1 vistos: ${JSON.stringify(vistos)}`);
expect(vistos.resolvidos.length, "cartão Resolvido:").toBeGreaterThan(0);
expect(
vistos.feed.filter((linha) => linha.includes("eliminou")).length,
"linha do feed com eliminou",
).toBeGreaterThan(0);
await expect(page.getByText(/^Você \d+ · Drones \d+$/)).toBeVisible();
// o nome acessível junta o texto e o trecho só para leitor de tela, com ou sem espaço antes dos dois-pontos
const links = page.getByRole("link", { name: /^Ver como eu resolvo isso/ });
const nomes: string[] = [];
for (const link of await links.all()) {
const nome = (await link.textContent())?.split(": ")[1] ?? "";
const problema = PROBLEMAS.find((p) => p.nome === nome);
expect(problema, `problema do link "${nome}"`).toBeDefined();
await expect(link).toHaveAttribute("href", problema?.destino ?? "");
nomes.push(nome);
}
console.log(`CA1 relatório: ${await titulo.textContent()} · ${nomes}`);
expect(new Set(nomes).size, "um link por problema").toBe(nomes.length);
for (const nome of vistos.resolvidos) expect(nomes).toContain(nome);
const chefao = problemaPorId("site-lento");
if ((await titulo.textContent()) === "Problemas resolvidos")
expect(nomes).toContain(chefao?.nome);
await expect(
// o rodapé do site tem outro "Chamar no WhatsApp", com a mensagem genérica
page
.getByRole("main")
.getByRole("link", { name: "Chamar no WhatsApp", exact: true }),
).toHaveAttribute(
"href",
new RegExp(encodeURIComponent(chefao?.nome ?? "?")),
);
expect(erros).toEqual([]);
});
for (const tela of [
{ width: 915, height: 412 },
{ width: 640, height: 360 },
]) {
test(`CA4: toque em ${tela.width}x${tela.height}: controles na tela, andar, mirar e acertar`, async ({
baseURL,
}) => {
test.setTimeout(200_000);
const page = await abrir(
{ viewport: tela, hasTouch: true, isMobile: true },
baseURL,
);
await page.goto("/jogo");
if (await sem3d(page)) return;
await comecarPartida(page, "Site lento");
const atirar = page.getByRole("button", { name: "Atirar", exact: true });
await expect(atirar).toBeVisible({ timeout: 30_000 });
const cdp = await page.context().newCDPSession(page);
const W = tela.width;
const H = tela.height;
// direcional na metade esquerda; empurrado para a frente, anda
const mover: Dedo = {
id: 1,
x: Math.round(W * 0.2),
y: Math.round(H * 0.6),
};
const parado = await estado(page);
await tocar(cdp, "touchStart", [mover]);
await expect(page.getByTestId("direcional")).toBeVisible();
await dentroDaTela(page.getByTestId("direcional"), tela);
await tocar(cdp, "touchMove", [{ ...mover, y: mover.y - 50 }]);
await page.waitForTimeout(1500);
const andou = await estado(page);
await tocar(cdp, "touchEnd", []);
expect(
Math.hypot(
(andou?.x ?? 0) - (parado?.x ?? 0),
(andou?.z ?? 0) - (parado?.z ?? 0),
),
`de ${JSON.stringify(parado)} para ${JSON.stringify(andou)}`,
).toBeGreaterThan(0.01);
await dentroDaTela(atirar, tela);
for (const arma of [/^Pistola$/, /^Automática/, /^Faca$/])
await dentroDaTela(page.getByRole("button", { name: arma }), tela);
// arrastar na metade direita gira
const mirar: Dedo = {
id: 2,
x: Math.round(W * 0.6),
y: Math.round(H * 0.3),
};
const rumo = (await estado(page))?.rumo;
await tocar(cdp, "touchStart", [mirar]);
for (let dx = 8; dx <= 80; dx += 8)
await tocar(cdp, "touchMove", [{ ...mirar, x: mirar.x + dx }]);
await tocar(cdp, "touchEnd", []);
await page.waitForTimeout(300);
expect((await estado(page))?.rumo, "rumo").not.toBe(rumo);
// segura Atirar e varre a mira em voltas
const caixa = await atirar.boundingBox();
if (!caixa) throw new Error("Atirar sem caixa");
const tiro: Dedo = {
id: 3,
x: Math.round(caixa.x + caixa.width / 2),
y: Math.round(caixa.y + caixa.height / 2),
};
const limite = Date.now() + 90_000;
let acertos = 0;
while (acertos < 1 && Date.now() < limite) {
const varrer: Dedo = { id: 4, x: Math.round(W * 0.55), y: mirar.y };
await tocar(cdp, "touchStart", [tiro]);
await tocar(cdp, "touchStart", [tiro, varrer]);
for (let dx = 6; dx <= W * 0.35 && acertos < 1; dx += 6) {
await tocar(cdp, "touchMove", [tiro, { ...varrer, x: varrer.x + dx }]);
await page.waitForTimeout(40);
acertos = (await estado(page))?.placar.jogador ?? 0;
}
await tocar(cdp, "touchEnd", []);
}
const fim = await estado(page);
expect(
fim?.placar.jogador,
`estado: ${JSON.stringify(fim)}`,
).toBeGreaterThanOrEqual(1);
});
}
test("CA4: em retrato, pede para virar o aparelho", async ({ baseURL }) => {
test.setTimeout(120_000);
const page = await abrir(
{ viewport: { width: 412, height: 915 }, hasTouch: true, isMobile: true },
baseURL,
);
await page.goto("/jogo");
if (await sem3d(page)) return;
await comecarPartida(page, "Site lento");
await expect(page.getByText("Vire o aparelho para jogar")).toBeVisible();
});
test("CA8: a home, rolada até o fim, não pede nada do jogo", async ({
browser,
baseURL,
request,
}) => {
test.setTimeout(180_000);
/** Caminhos dos scripts que a página pede até a rede parar. */
async function scripts(caminho: string) {
const contexto = await browser.newContext({ baseURL });
contextos.push(contexto);
const page = await contexto.newPage();
const pedidos = new Set<string>();
page.on("request", (pedido) => {
if (pedido.resourceType() === "script")
pedidos.add(new URL(pedido.url()).pathname);
});
await page.goto(caminho);
await page.waitForLoadState("networkidle");
return pedidos;
}
// Blocos da cena: dos scripts que a partida automática pede a mais que a tela inicial parada, os que trazem o
// código da cena (ela publica `window.__jogo`). O three em si fica de fora: o fundo 3D da home
// (HeroField → BlocosScene) já o carrega sob demanda, e é o mesmo arquivo.
const parada = await scripts("/jogo");
const novos = [
...(await scripts("/jogo?piloto=1&problema=site-lento")),
].filter((caminho) => !parada.has(caminho));
const blocos: string[] = [];
for (const caminho of novos)
if ((await (await request.get(caminho)).text()).includes("__jogo"))
blocos.push(caminho);
console.log(`CA8 pedidos da partida: ${novos.join(" ")}`);
console.log(`CA8 blocos da cena: ${blocos.join(" ")}`);
expect(blocos.length, "blocos da cena achados").toBeGreaterThan(0);
const contexto = await browser.newContext({ baseURL });
contextos.push(contexto);
const page = await contexto.newPage();
const pedidos: string[] = [];
page.on("request", (pedido) => {
pedidos.push(pedido.url());
});
await page.goto("/");
for (let i = 0; i < 100; i += 1) {
const noFim = await page.evaluate(() => {
window.scrollBy(0, window.innerHeight * 0.8);
return (
window.scrollY + window.innerHeight >=
document.documentElement.scrollHeight - 2
);
});
await page.waitForTimeout(150);
if (noFim) break;
}
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1500);
const doJogo = pedidos.filter(
(url) => url.includes("/jogo/") || blocos.includes(new URL(url).pathname),
);
console.log(
`CA8 da partida também pedidos pela home: ${novos.filter((caminho) => pedidos.some((url) => new URL(url).pathname === caminho))}`,
);
expect(doJogo).toEqual([]);
});
test("CA9: três partidas seguidas sem acumular geometria, textura nem memória", async ({
baseURL,
}) => {
test.setTimeout(900_000);
const page = await abrir({ viewport: DESKTOP }, baseURL);
// semente fixa na 1ª partida; "Jogar de novo" sorteia outra
await page.goto("/jogo?piloto=1&semente=1&problema=site-lento&vel=16");
if (await sem3d(page)) return;
const cdp = await page.context().newCDPSession(page);
const jogarDeNovo = page.getByRole("button", { name: "Jogar de novo" });
const medidas: {
geometrias?: number;
texturas?: number;
heap: number | null;
sobras?: { geometrias: number; texturas: number };
}[] = [];
for (let i = 0; i < 3; i += 1) {
if (i > 0) await jogarDeNovo.click();
const pronto = await page.waitForFunction(
() => {
const j = window.__jogo;
return j?.horda === 1 && j.fase === "horda"
? { geometrias: j.geometrias, texturas: j.texturas }
: null;
},
null,
{ timeout: 180_000, polling: 50 },
);
const contagem = await pronto.jsonValue();
// coleta completa pelo protocolo do Chromium antes de medir
await cdp.send("HeapProfiler.collectGarbage");
const heap = await page.evaluate(() => {
// `performance.memory` só existe no Chromium (preciso com as flags de MEMORIA); os tipos do DOM não o trazem
const desempenho = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return desempenho.memory?.usedJSHeapSize ?? null;
});
// no fim da partida a cena é descartada e publica o que o renderer ainda contava
await expect(jogarDeNovo).toBeVisible({ timeout: 300_000 });
const sobras = await page.evaluate(() => window.__jogo?.sobras);
medidas.push({ ...contagem, heap, sobras });
console.log(`CA9 partida ${i + 1}: ${JSON.stringify(medidas[i])}`);
}
const [primeira, , terceira] = medidas;
expect(terceira.geometrias, "geometrias").toBe(primeira.geometrias);
expect(terceira.texturas, "texturas").toBe(primeira.texturas);
for (const [i, { sobras }] of medidas.entries())
expect(sobras, `sobras da partida ${i + 1}`).toEqual({
geometrias: 0,
texturas: 0,
});
if (primeira.heap !== null && terceira.heap !== null)
expect(terceira.heap, "heap JS").toBeLessThanOrEqual(primeira.heap * 1.1);
});
test.describe("CA11: acesso", () => {
test("sem JavaScript, mostra os 27 problemas com link", async ({
browser,
baseURL,
}) => {
const contexto = await browser.newContext({
baseURL,
javaScriptEnabled: false,
});
contextos.push(contexto);
const page = await contexto.newPage();
await page.goto("/jogo");
const lista = page.locator('section[aria-labelledby="lista-de-problemas"]');
await expect(lista.getByRole("link")).toHaveCount(27);
for (const problema of PROBLEMAS) {
const item = lista
.getByRole("listitem")
.filter({ hasText: problema.nome });
await expect(item.getByRole("link")).toHaveAttribute(
"href",
problema.destino,
);
}
});
test("com movimento reduzido, oferece Jogar mesmo assim, e o aviso de sangue vem antes", async ({
baseURL,
}) => {
const page = await abrir(
{ viewport: DESKTOP, reducedMotion: "reduce" },
baseURL,
);
await page.goto("/jogo");
await expect(
page.getByText("Tem sangue e sustos. É um jogo de tiro."),
).toBeVisible();
if (await sem3d(page)) return;
await expect(
page.getByRole("button", { name: "Jogar mesmo assim" }),
).toBeVisible();
});
test("no computador, o mudo alterna e perder a trava do mouse pausa", async ({
baseURL,
}) => {
test.setTimeout(120_000);
const page = await abrir({ viewport: DESKTOP }, baseURL);
await page.goto("/jogo");
if (await sem3d(page)) return;
await comecarPartida(page, "Site lento");
const desligar = page.getByRole("button", { name: "Desligar o som" });
const ligar = page.getByRole("button", { name: "Ligar o som" });
await desligar.click();
await expect(ligar).toBeVisible();
await ligar.click();
await expect(desligar).toBeVisible();
const pausado = page.getByRole("heading", { name: "Pausado" });
await page.mouse.click(DESKTOP.width / 2, DESKTOP.height / 2);
const travou = await page
.waitForFunction(() => document.pointerLockElement !== null, null, {
timeout: 5_000,
})
.then(
() => true,
() => false,
);
test.info().annotations.push({
type: "trava do mouse",
description: travou ? "obtida ao clicar" : "não obtida no clique",
});
console.log(`CA11 trava do mouse obtida: ${travou}`);
await page.keyboard.press("Escape");
const pausouComEsc = await pausado.waitFor({ timeout: 3_000 }).then(
() => true,
() => false,
);
console.log(`CA11 Esc pausou: ${pausouComEsc}`);
if (!pausouComEsc) {
test.info().annotations.push({
type: "pausa",
description: "Esc não pausou; conferido saindo da trava do mouse",
});
await page.evaluate(() => document.exitPointerLock());
}
await expect(pausado).toBeVisible();
});
test("axe sem violação serious ou critical na tela inicial", async ({
baseURL,
}) => {
const page = await abrir({ viewport: DESKTOP }, baseURL);
await page.goto("/jogo", { waitUntil: "load" });
await page.waitForTimeout(800);
expect(await axeSerio(page)).toEqual([]);
});
test("axe sem violação serious ou critical no relatório", async ({
baseURL,
}) => {
test.setTimeout(300_000);
const page = await abrir({ viewport: DESKTOP }, baseURL);
await page.goto("/jogo?piloto=1&semente=1&problema=site-lento&vel=16");
if (await sem3d(page)) return;
await expect(
page.getByRole("heading", { level: 1, name: FIM }),
).toBeVisible({ timeout: 240_000 });
await page.waitForTimeout(800);
expect(await axeSerio(page)).toEqual([]);
});
});
test("CA13: /jogo e /jogo/bancada trazem noindex", async ({ page }) => {
for (const caminho of ["/jogo", "/jogo/bancada"]) {
await page.goto(caminho);
await expect(page.locator('meta[name="robots"]'), caminho).toHaveAttribute(
"content",
/noindex/,
);
}
});