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 { 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 { 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(); 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/, ); } });