test(e2e): fluxos de contato, navegação, teclado e menu do celular mais rígidos
This commit is contained in:
1 parent
8903f3c57e
commit
02298779df
8 files changed
+321
-115
No files matched your search
+91
-28
@@ -1,15 +1,19 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { DESKTOP } from "./helpers";
|
||||
import { DESKTOP_USE, PHONE_USE } from "./helpers";
|
||||
|
||||
/**
|
||||
* (h) Contato, no computador (1440x900):
|
||||
* - o botão de cada oferta da home ("Escolha por onde começar") leva o formulário (#contact) para a tela, com o tipo
|
||||
* de projeto da oferta já escolhido, e um clique seguinte em "Falar sobre meu projeto" do cabeçalho continua
|
||||
* rolando até #contact (a página rola para outro lugar entre os dois cliques);
|
||||
* (h) Contato, no computador (1440x900) e, onde dito, no celular (390x844, toque):
|
||||
* - o botão de cada oferta da home ("Escolha por onde começar") leva o cartão do formulário (#contact-form) para a
|
||||
* tela, com o tipo de projeto da oferta já escolhido e o endereço `/#contact-form` (o `?tipo=` sai), e um clique
|
||||
* seguinte em "Falar sobre meu projeto" do cabeçalho continua rolando até #contact (a página rola para outro lugar
|
||||
* entre os dois cliques);
|
||||
* - o link direto `/?tipo=<tipo>#contact-form` (quem chega de outra página) abre a home com o formulário na tela e o
|
||||
* tipo marcado;
|
||||
* - no celular, o toque no botão da oferta deixa o cartão do formulário e o campo do tipo à vista;
|
||||
* - de /privacidade, o botão do cabeçalho leva a /#contact e deixa o foco dentro de #contact;
|
||||
* - com movimento reduzido, dois cliques seguidos no link /#contact do cabeçalho rolam nos dois.
|
||||
* "Formulário na tela": o título do contato e o campo "Tipo de projeto" ficam inteiros dentro da janela e abaixo do
|
||||
* cabeçalho fixo.
|
||||
* "Formulário na tela": o título do contato e o campo "Tipo de projeto" ficam inteiros dentro da janela, abaixo do
|
||||
* cabeçalho fixo e acima da barra de cookies, quando ela está aberta (primeira visita, em qualquer servidor).
|
||||
*/
|
||||
|
||||
const CTA_LABEL = "Falar sobre meu projeto";
|
||||
@@ -19,12 +23,12 @@ const CTA_LABEL = "Falar sobre meu projeto";
|
||||
* (app/components/contact-subject.ts): uma oferta nova sem entrada aqui reprova o primeiro teste.
|
||||
*/
|
||||
const SUBJECT_BY_KIND = {
|
||||
site: "Site para empresa",
|
||||
sistema: "Sistemas web sob medida",
|
||||
revisao: "Revisão ou diagnóstico de sistema",
|
||||
site: "Site que traz pedidos pelo WhatsApp",
|
||||
sistema: "Sistema para tirar a operação da planilha",
|
||||
revisao: "Revisão de sistema que já existe",
|
||||
} as const;
|
||||
|
||||
test.use({ viewport: DESKTOP });
|
||||
test.use(DESKTOP_USE);
|
||||
|
||||
/** Abre a página e espera o tempo de a hidratação assentar, como os demais testes de interação. */
|
||||
async function open(page: Page, path: string) {
|
||||
@@ -38,9 +42,13 @@ const headerContact = (page: Page) =>
|
||||
const scrollToTop = (page: Page) =>
|
||||
page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" }));
|
||||
|
||||
/** "ok" se o título e o campo de tipo do contato estão inteiros na janela e abaixo do cabeçalho; senão, onde estão. */
|
||||
function contactFormOnScreen(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
/**
|
||||
* "ok" se as partes do contato estão inteiras na janela, abaixo do cabeçalho e acima da barra de cookies (se aberta);
|
||||
* senão, onde estão. `card`: o destino das ofertas (o cartão do formulário e o campo de tipo); sem ele, o destino do
|
||||
* botão do cabeçalho (o título do contato e o campo de tipo).
|
||||
*/
|
||||
function contactFormOnScreen(page: Page, card = false) {
|
||||
return page.evaluate((onlyCard) => {
|
||||
const box = (selector: string) => {
|
||||
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
||||
return rect
|
||||
@@ -50,24 +58,39 @@ function contactFormOnScreen(page: Page) {
|
||||
const headerBottom = Math.round(
|
||||
document.querySelector("header")?.getBoundingClientRect().bottom ?? 0,
|
||||
);
|
||||
const parts = {
|
||||
título: box("#contact h2"),
|
||||
"campo de tipo": box("#contact select[name='subject']"),
|
||||
};
|
||||
// a barra de cookies fixa na base cobre o que estiver abaixo do topo dela
|
||||
const barTop = document
|
||||
.getElementById("cookie-bar")
|
||||
?.getBoundingClientRect().top;
|
||||
const limit = Math.min(innerHeight, barTop ?? innerHeight);
|
||||
const parts = onlyCard
|
||||
? {
|
||||
"cartão do formulário": box("#contact-form"),
|
||||
"campo de tipo": box("#contact select[name='subject']"),
|
||||
}
|
||||
: {
|
||||
título: box("#contact h2"),
|
||||
"campo de tipo": box("#contact select[name='subject']"),
|
||||
};
|
||||
const hidden = Object.entries(parts).filter(
|
||||
([, rect]) =>
|
||||
!rect || rect.top < headerBottom - 1 || rect.bottom > innerHeight + 1,
|
||||
([name, rect]) =>
|
||||
!rect ||
|
||||
rect.top < headerBottom - 1 ||
|
||||
// o cartão inteiro pode ser mais alto que a janela: basta o topo dele estar à vista
|
||||
(name === "cartão do formulário"
|
||||
? rect.top > limit + 1
|
||||
: rect.bottom > limit + 1),
|
||||
);
|
||||
return hidden.length === 0
|
||||
? "ok"
|
||||
: `fora da tela: ${hidden.map(([name]) => name).join(", ")}; ${JSON.stringify({ ...parts, headerBottom, janela: innerHeight, rolagem: Math.round(scrollY) })}`;
|
||||
});
|
||||
: `fora da tela: ${hidden.map(([name]) => name).join(", ")}; ${JSON.stringify({ ...parts, headerBottom, janela: innerHeight, barraDeCookies: barTop ?? null, rolagem: Math.round(scrollY) })}`;
|
||||
}, card);
|
||||
}
|
||||
|
||||
/** Espera a rolagem (suave ou não) levar o formulário de contato para a tela. */
|
||||
async function expectContactOnScreen(page: Page, what: string) {
|
||||
async function expectContactOnScreen(page: Page, what: string, card = false) {
|
||||
await expect
|
||||
.poll(() => contactFormOnScreen(page), {
|
||||
.poll(() => contactFormOnScreen(page, card), {
|
||||
message: `${what}: o formulário de contato na tela`,
|
||||
timeout: 8_000,
|
||||
})
|
||||
@@ -83,24 +106,28 @@ test("a home tem as três ofertas e cada uma tem o seu tipo de projeto", async (
|
||||
.evaluateAll((links) => links.map((link) => link.getAttribute("href")));
|
||||
expect(hrefs.sort()).toEqual(
|
||||
Object.keys(SUBJECT_BY_KIND)
|
||||
.map((kind) => `/?tipo=${kind}#contact`)
|
||||
.map((kind) => `/?tipo=${kind}#contact-form`)
|
||||
.sort(),
|
||||
);
|
||||
});
|
||||
|
||||
for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) {
|
||||
test(`oferta "${kind}": o botão leva o contato à tela com "${subject}" marcado, e o botão do cabeçalho rola de novo até ele`, async ({
|
||||
test(`oferta "${kind}": o botão leva o formulário à tela com "${subject}" marcado, e o botão do cabeçalho rola de novo até o contato`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await open(page, "/");
|
||||
const offer = page.locator(`#modalities a[href='/?tipo=${kind}#contact']`);
|
||||
const offer = page.locator(
|
||||
`#modalities a[href='/?tipo=${kind}#contact-form']`,
|
||||
);
|
||||
await offer.scrollIntoViewIfNeeded();
|
||||
await offer.click();
|
||||
|
||||
await expectContactOnScreen(page, `depois da oferta "${kind}"`);
|
||||
await expectContactOnScreen(page, `depois da oferta "${kind}"`, true);
|
||||
await expect(page.locator("#contact select[name='subject']")).toHaveValue(
|
||||
subject,
|
||||
);
|
||||
// o endereço fica na home, no cartão do formulário, sem o ?tipo= (o tipo já está no formulário)
|
||||
await expect(page).toHaveURL(/\/#contact-form$/);
|
||||
|
||||
// a pessoa lê outro trecho da página e usa o botão do cabeçalho: ele ainda rola até o contato
|
||||
await scrollToTop(page);
|
||||
@@ -114,6 +141,42 @@ for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) {
|
||||
test(`link direto /?tipo=${kind}#contact-form: abre a home com o formulário à vista e "${subject}" marcado`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/?tipo=${kind}#contact-form`, { waitUntil: "load" });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await expectContactOnScreen(page, `link direto "${kind}"`, true);
|
||||
await expect(page.locator("#contact select[name='subject']")).toHaveValue(
|
||||
subject,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("no celular (toque)", () => {
|
||||
test.use(PHONE_USE);
|
||||
|
||||
for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) {
|
||||
test(`oferta "${kind}": o toque deixa o cartão do formulário e o campo de tipo à vista, com "${subject}" marcado`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await open(page, "/");
|
||||
const offer = page.locator(
|
||||
`#modalities a[href='/?tipo=${kind}#contact-form']`,
|
||||
);
|
||||
await offer.scrollIntoViewIfNeeded();
|
||||
await offer.tap();
|
||||
|
||||
await expectContactOnScreen(page, `toque na oferta "${kind}"`, true);
|
||||
await expect(page.locator("#contact select[name='subject']")).toHaveValue(
|
||||
subject,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("de /privacidade, o botão do cabeçalho leva a /#contact e deixa o foco dentro de #contact", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
+55
-3
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { APIRequestContext } from "@playwright/test";
|
||||
import { type APIRequestContext, expect, type Page } 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) {
|
||||
@@ -21,8 +21,8 @@ let devServer: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* O `BASE_URL` é um servidor de desenvolvimento (`next dev`)? O HTML dele traz o cliente de recarga a quente (HMR) e o
|
||||
* React Refresh, que o build de produção não tem. Serve para as poucas coisas que só existem em um dos dois: a barra
|
||||
* de cookies (só em produção) e avisos do React (só no desenvolvimento).
|
||||
* React Refresh, que o build de produção não tem. Serve para a única coisa que só existe em um dos dois: avisos do
|
||||
* React que só o desenvolvimento emite. (A barra de cookies é a mesma nos dois, e o gtag só carrega em produção.)
|
||||
*/
|
||||
export function isDevServer(request: APIRequestContext) {
|
||||
devServer ??= request
|
||||
@@ -55,3 +55,55 @@ export const CONTACT_HEADING =
|
||||
|
||||
export const DESKTOP = { width: 1440, height: 900 } as const;
|
||||
export const PHONE = { width: 390, height: 844 } as const;
|
||||
|
||||
/** Opções de `test.use` do computador. */
|
||||
export const DESKTOP_USE = { viewport: DESKTOP } as const;
|
||||
|
||||
/**
|
||||
* Opções de `test.use` de um celular de verdade: a janela de 390x844 mais `isMobile` (viewport de celular, `<meta
|
||||
* viewport>` valendo) e `hasTouch` (toque, `tap()`), e não só uma janela estreita.
|
||||
*/
|
||||
export const PHONE_USE = {
|
||||
viewport: PHONE,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
deviceScaleFactor: 2,
|
||||
} as const;
|
||||
|
||||
/** Chave do consentimento no localStorage (`CONSENT_STORAGE_KEY` em lib/analytics.ts). */
|
||||
export const CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1";
|
||||
|
||||
/** Os controles da barra de cookies, na ordem do Tab. */
|
||||
export const COOKIE_BAR_CONTROLS = [
|
||||
"Política de privacidade",
|
||||
"Aceitar",
|
||||
"Recusar",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Deixa o consentimento já salvo no navegador (como quem aceitou ou recusou numa visita anterior): chamar antes do
|
||||
* `page.goto`. Com ele a barra de cookies não abre. "denied" por padrão, para o gtag não carregar em produção.
|
||||
*/
|
||||
export async function saveConsent(
|
||||
page: Page,
|
||||
value: "granted" | "denied" = "denied",
|
||||
) {
|
||||
await page.addInitScript(
|
||||
([key, stored]) => window.localStorage.setItem(key, stored),
|
||||
[CONSENT_STORAGE_KEY, value],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A barra de cookies da primeira visita (id `cookie-bar`, `COOKIE_BAR_ID` em lib/analytics.ts), fixa na base da tela,
|
||||
* antes do cabeçalho no documento. Ela é a mesma em qualquer servidor, de desenvolvimento ou de produção (só o
|
||||
* carregamento do gtag depende do ambiente): se não aparecer, o teste reprova. Devolve o localizador da barra.
|
||||
*/
|
||||
export async function cookieBar(page: Page) {
|
||||
const bar = page.getByRole("dialog", { name: "Cookies e privacidade" });
|
||||
await expect(
|
||||
bar,
|
||||
"a barra de cookies não apareceu na primeira visita",
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
return bar;
|
||||
}
|
||||
+54
-7
@@ -1,12 +1,19 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { DESKTOP } from "./helpers";
|
||||
import {
|
||||
COOKIE_BAR_CONTROLS,
|
||||
cookieBar,
|
||||
DESKTOP_USE,
|
||||
saveConsent,
|
||||
} from "./helpers";
|
||||
|
||||
/**
|
||||
* (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é 36 toques
|
||||
* (a vitrine de projetos é navegada com as setas: um seletor e o link do case; ver o inventário em LINEAR_MAX_TABS).
|
||||
* (b) caminho linear: do topo da home, o Tab chega ao campo de nome do contato em até 36 toques, mais os controles da
|
||||
* barra de cookies enquanto ela estiver aberta (primeira visita: 36 + 3 = 39; com o consentimento já salvo a barra
|
||||
* não abre e o limite é 36). A vitrine de projetos é navegada com as setas: um seletor e o link do case; ver o
|
||||
* inventário em LINEAR_MAX_TABS.
|
||||
* Os testes continuam apertando Tab depois do limite só para dizer, na falha, quantos toques faltavam.
|
||||
*/
|
||||
|
||||
@@ -25,9 +32,12 @@ const SHORTCUT_MAX_TABS_AFTER_ENTER = 4;
|
||||
* Tudo isso é conteúdo e navegação que a pessoa quer alcançar; o limite não deve subir sem uma parada nova justificada.
|
||||
*/
|
||||
const LINEAR_MAX_TABS = 36;
|
||||
/** Primeira visita: a barra de cookies abre antes do cabeçalho e soma os controles dela (link da política, Aceitar, Recusar). */
|
||||
const LINEAR_MAX_TABS_FIRST_VISIT =
|
||||
LINEAR_MAX_TABS + COOKIE_BAR_CONTROLS.length;
|
||||
const LINEAR_GIVE_UP_AFTER = 120;
|
||||
|
||||
test.use({ viewport: DESKTOP });
|
||||
test.use(DESKTOP_USE);
|
||||
|
||||
/** 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) {
|
||||
@@ -56,12 +66,22 @@ async function focusedElement(page: Page) {
|
||||
}, CTA_LABEL);
|
||||
}
|
||||
|
||||
/** Visita nova: foco no documento, topo da página. */
|
||||
async function openHomeAsNewVisitor(page: Page) {
|
||||
/**
|
||||
* Visita nova: foco no documento, topo da página. Com `consent`, o consentimento já estava salvo no navegador (a barra
|
||||
* de cookies não abre); sem ele é a primeira visita, e a barra de cookies tem de estar aberta.
|
||||
*/
|
||||
async function openHomeAsNewVisitor(
|
||||
page: Page,
|
||||
consent?: "granted" | "denied",
|
||||
) {
|
||||
if (consent) await saveConsent(page, consent);
|
||||
await page.goto("/", { waitUntil: "load" });
|
||||
await page.waitForTimeout(800);
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement)?.blur());
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" }));
|
||||
const bar = page.getByRole("dialog", { name: "Cookies e privacidade" });
|
||||
if (consent) await expect(bar).toHaveCount(0);
|
||||
else await cookieBar(page);
|
||||
}
|
||||
|
||||
/** Aperta Tab até o foco satisfazer `stop`, no máximo `giveUpAfter` vezes. `reached` é 0 se nunca satisfez. */
|
||||
@@ -116,10 +136,37 @@ test(`atalho: Tab até "${CTA_LABEL}" do cabeçalho, Enter, e o campo de nome em
|
||||
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 ({
|
||||
test(`caminho linear, primeira visita (barra de cookies aberta): do topo da home, Tab chega ao campo de nome do contato em até ${LINEAR_MAX_TABS_FIRST_VISIT} toques (${LINEAR_MAX_TABS} + os ${COOKIE_BAR_CONTROLS.length} controles da barra)`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomeAsNewVisitor(page);
|
||||
const bar = await cookieBar(page);
|
||||
expect(
|
||||
await bar.locator("a[href], button").count(),
|
||||
"controles focáveis da barra de cookies",
|
||||
).toBe(COOKIE_BAR_CONTROLS.length);
|
||||
|
||||
const { reached, visited } = await tabUntil(
|
||||
page,
|
||||
({ isName }) => isName,
|
||||
LINEAR_GIVE_UP_AFTER,
|
||||
);
|
||||
|
||||
expect(
|
||||
reached,
|
||||
`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 ${LINEAR_MAX_TABS_FIRST_VISIT}); passou por:\n${visited.join("\n")}`,
|
||||
).toBeLessThanOrEqual(LINEAR_MAX_TABS_FIRST_VISIT);
|
||||
await expect(page.locator("#contact input[name='name']")).toBeFocused();
|
||||
});
|
||||
|
||||
test(`caminho linear, com o consentimento já salvo (sem a barra de cookies): Tab chega ao campo de nome do contato em até ${LINEAR_MAX_TABS} toques`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomeAsNewVisitor(page, "denied");
|
||||
|
||||
const { reached, visited } = await tabUntil(
|
||||
page,
|
||||
|
||||
+15
-57
@@ -1,8 +1,9 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { isDevServer, PHONE } from "./helpers";
|
||||
import { COOKIE_BAR_CONTROLS, cookieBar, PHONE_USE } 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):
|
||||
* (g) Menu do celular (390x844, com toque: `isMobile` e `hasTouch`, os toques são `tap()`), numa visita nova (a barra de
|
||||
* cookies está aberta, fixa na base da tela, em qualquer servidor):
|
||||
* - 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 e na barra de cookies com o menu aberto, nos dois sentidos, e a volta passa pelos
|
||||
@@ -15,7 +16,7 @@ const CTA_LABEL = "Falar sobre meu projeto";
|
||||
const LOGO_LABEL = "Evandro.dev.br — início";
|
||||
const CLOSE_LABEL = "Fechar menu";
|
||||
/** Os três controles da barra de cookies, na ordem do Tab. */
|
||||
const BAR_CONTROLS = ["Política de privacidade", "Aceitar", "Recusar"];
|
||||
const BAR_CONTROLS = COOKIE_BAR_CONTROLS;
|
||||
|
||||
/** `run` aparece em `sequence`, em posições seguidas? */
|
||||
function hasRun(sequence: string[], run: string[]) {
|
||||
@@ -24,7 +25,7 @@ function hasRun(sequence: string[], run: string[]) {
|
||||
);
|
||||
}
|
||||
|
||||
test.use({ viewport: PHONE });
|
||||
test.use(PHONE_USE);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "load" });
|
||||
@@ -39,23 +40,23 @@ const mobileNav = (page: Page) =>
|
||||
page.getByRole("navigation", { name: "Navegação mobile" });
|
||||
|
||||
/**
|
||||
* Espera o cabeçalho responder ao clique (a hidratação terminou), só pelo que o usuário vê: abre o menu pelo botão,
|
||||
* repetindo o clique enquanto ele não responde, e fecha de novo pelo mesmo botão. Deixa o menu fechado.
|
||||
* Espera o cabeçalho responder ao toque (a hidratação terminou), só pelo que o usuário vê: abre o menu pelo botão,
|
||||
* repetindo o toque enquanto ele não responde, e fecha de novo pelo mesmo botão. Deixa o menu fechado.
|
||||
*/
|
||||
async function waitUntilInteractive(page: Page) {
|
||||
await expect(openMenuButton(page)).toBeVisible();
|
||||
await openMenu(page);
|
||||
await closeMenuButton(page).click();
|
||||
await closeMenuButton(page).tap();
|
||||
await expect(openMenuButton(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(mobileNav(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
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
|
||||
// repete o toque enquanto o botão ainda for "Abrir menu": uma recarga do servidor de desenvolvimento (HMR) no meio
|
||||
// do teste, ou um toque 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 openMenuButton(page).tap({ timeout: 2_000 });
|
||||
await expect(closeMenuButton(page)).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
@@ -65,52 +66,6 @@ async function openMenu(page: Page) {
|
||||
await expect(mobileNav(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* A barra de cookies da página (id `cookie-bar`, `COOKIE_BAR_ID` em lib/analytics.ts). Ela só é renderizada no build
|
||||
* de produção (GoogleAnalytics.tsx). Num servidor de desenvolvimento entra no lugar dela uma barra de mesmo contrato
|
||||
* (id, fixa na base, 96 px, `--cookie-bar-height` publicada na raiz, antes do cabeçalho, com o link da política,
|
||||
* Aceitar e Recusar), para os testes conferirem o lado do cabeçalho. Num build de produção a ausência da barra real
|
||||
* reprova.
|
||||
*/
|
||||
async function cookieBar(page: Page) {
|
||||
const bar = page.getByRole("dialog", { name: "Cookies e privacidade" });
|
||||
const isReal = await bar.waitFor({ state: "visible", timeout: 3_000 }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (isReal) return bar;
|
||||
|
||||
expect(
|
||||
await isDevServer(page.request),
|
||||
"a barra de cookies não apareceu num build de produção",
|
||||
).toBe(true);
|
||||
await page.evaluate(() => {
|
||||
const stand = document.createElement("section");
|
||||
stand.id = "cookie-bar";
|
||||
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";
|
||||
const text = document.createElement("p");
|
||||
text.append("Usamos métricas de navegação do Google só se você permitir. ");
|
||||
const policy = document.createElement("a");
|
||||
policy.href = "/privacidade";
|
||||
policy.textContent = "Política de privacidade";
|
||||
text.append(policy);
|
||||
const accept = document.createElement("button");
|
||||
accept.type = "button";
|
||||
accept.textContent = "Aceitar";
|
||||
const decline = document.createElement("button");
|
||||
decline.type = "button";
|
||||
decline.textContent = "Recusar";
|
||||
stand.append(text, accept, decline);
|
||||
document.querySelector("header")?.before(stand);
|
||||
document.documentElement.style.setProperty("--cookie-bar-height", "96px");
|
||||
});
|
||||
await expect(bar).toBeVisible();
|
||||
return bar;
|
||||
}
|
||||
|
||||
test("o menu abre pelo botão e mostra os links e o contato", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -136,6 +91,9 @@ test("o botão de contato do menu não fica sob a barra de cookies", async ({
|
||||
const barBox = await bar.boundingBox();
|
||||
if (!box || !barBox) throw new Error("sem caixa para o botão ou a barra");
|
||||
|
||||
// um toque no botão chegaria a ele (a checagem de "recebe o toque" do Playwright, sem navegar)
|
||||
await contact.tap({ trial: true });
|
||||
|
||||
// o botão termina acima do topo da barra...
|
||||
expect(
|
||||
box.y + box.height,
|
||||
@@ -259,7 +217,7 @@ test("Esc fecha o menu e devolve o foco ao botão do menu", async ({ page }) =>
|
||||
test("navegar por um link do menu fecha o menu", async ({ page }) => {
|
||||
await openMenu(page);
|
||||
|
||||
await mobileNav(page).getByRole("link", { name: "Cases" }).click();
|
||||
await mobileNav(page).getByRole("link", { name: "Cases" }).tap();
|
||||
await page.waitForURL("**/cases");
|
||||
|
||||
await expect(mobileNav(page)).toHaveCount(0);
|
||||
|
||||
+88
-2
@@ -1,14 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { DESKTOP } from "./helpers";
|
||||
import { DESKTOP_USE, PHONE_USE } 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.
|
||||
* (k) De uma página rolada para outra (/servicos -> /servicos/criacao-de-sites, /cases -> um case), a página nova abre em
|
||||
* scrollY === 0, sem animação: a cada quadro, a partir do clique, nunca se vê a página nova numa altura diferente de
|
||||
* 0 (o `data-scroll-behavior` do <html> faz o Next desligar a rolagem suave só durante a troca de rota). No
|
||||
* computador com clique, no celular (`isMobile`, `hasTouch`) com toque.
|
||||
*/
|
||||
|
||||
type Probe = { __marker?: string; __viewTransitions?: number };
|
||||
|
||||
test.use({ viewport: DESKTOP });
|
||||
test.use(DESKTOP_USE);
|
||||
|
||||
test("menu Cases e depois um case: sem recarregar o documento, uma transição de página por navegação", async ({
|
||||
page,
|
||||
@@ -61,3 +65,85 @@ test("menu Cases e depois um case: sem recarregar o documento, uma transição d
|
||||
"o documento foi recarregado (o marcador sumiu)",
|
||||
).toBe("mesmo-documento");
|
||||
});
|
||||
|
||||
const SCROLLED_NAVIGATIONS = [
|
||||
{
|
||||
from: "/servicos",
|
||||
link: "a[href='/servicos/criacao-de-sites']",
|
||||
to: /\/servicos\/criacao-de-sites$/,
|
||||
},
|
||||
{ from: "/cases", link: "a[href^='/cases/']", to: /\/cases\/[^/]+$/ },
|
||||
] as const;
|
||||
|
||||
const DEVICES = [
|
||||
{ name: "computador, clique", use: DESKTOP_USE, tap: false },
|
||||
{ name: "celular, toque", use: PHONE_USE, tap: true },
|
||||
] as const;
|
||||
|
||||
for (const { name, use, tap } of DEVICES) {
|
||||
test.describe(`página nova abre no topo, ${name}`, () => {
|
||||
test.use(use);
|
||||
|
||||
for (const { from, link, to } of SCROLLED_NAVIGATIONS) {
|
||||
test(`${from} rolado -> próxima página: scrollY 0 em todos os quadros, sem deslizar`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(from, { waitUntil: "load" });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const target = page.locator(link).first();
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
await page.evaluate(() => {
|
||||
const maxScroll = document.documentElement.scrollHeight - innerHeight;
|
||||
window.scrollTo({
|
||||
top: Math.min(1200, maxScroll),
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const before = await page.evaluate(() => Math.round(scrollY));
|
||||
expect(before, `${from}: a página começa rolada`).toBeGreaterThan(300);
|
||||
await expect(target).toBeVisible();
|
||||
|
||||
// a cada quadro desenhado, o caminho e a rolagem que a pessoa vê, até a nova página assentar
|
||||
await page.evaluate(() => {
|
||||
const frames: Array<{ path: string; y: number }> = [];
|
||||
(window as unknown as { __frames: typeof frames }).__frames = frames;
|
||||
const startedAt = performance.now();
|
||||
const sample = () => {
|
||||
frames.push({ path: location.pathname, y: Math.round(scrollY) });
|
||||
if (performance.now() - startedAt < 4_000)
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
if (tap) await target.tap();
|
||||
else await target.click();
|
||||
await page.waitForURL(to);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await page.waitForTimeout(4_200);
|
||||
|
||||
const { frames, finalY, path } = await page.evaluate(() => ({
|
||||
frames: (
|
||||
window as unknown as {
|
||||
__frames: Array<{ path: string; y: number }>;
|
||||
}
|
||||
).__frames,
|
||||
finalY: Math.round(scrollY),
|
||||
path: location.pathname,
|
||||
}));
|
||||
const onNewPage = frames.filter((frame) => frame.path === path);
|
||||
expect(
|
||||
onNewPage.length,
|
||||
"quadros vistos na página nova",
|
||||
).toBeGreaterThan(5);
|
||||
const notAtTop = onNewPage.filter((frame) => frame.y !== 0);
|
||||
expect(
|
||||
notAtTop.slice(0, 8),
|
||||
`a página nova (${path}) foi vista fora do topo em ${notAtTop.length} de ${onNewPage.length} quadros (antes: ${before}px)`,
|
||||
).toEqual([]);
|
||||
expect(finalY, "rolagem final da página nova").toBe(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
+8
-8
@@ -1,10 +1,10 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { DESKTOP, isDevServer, PHONE, sitemapPaths } from "./helpers";
|
||||
import { DESKTOP_USE, isDevServer, PHONE_USE, 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).
|
||||
* (1440x900) e no celular (390x844, `isMobile` e `hasTouch`).
|
||||
* (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.
|
||||
*
|
||||
@@ -19,13 +19,13 @@ const DEV_ONLY_KEY_WARNING =
|
||||
/Each child in a list should have a unique "key" prop/;
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "computador 1440x900", size: DESKTOP },
|
||||
{ name: "celular 390x844", size: PHONE },
|
||||
{ name: "computador 1440x900", use: DESKTOP_USE },
|
||||
{ name: "celular 390x844 (isMobile, com toque)", use: PHONE_USE },
|
||||
] as const;
|
||||
|
||||
for (const { name, size } of VIEWPORTS) {
|
||||
for (const { name, use } of VIEWPORTS) {
|
||||
test.describe(`rotas do sitemap, ${name}`, () => {
|
||||
test.use({ viewport: size });
|
||||
test.use(use);
|
||||
|
||||
test("respondem 200, sem erro de console e sem rolagem horizontal", async ({
|
||||
page,
|
||||
@@ -84,9 +84,9 @@ for (const { name, size } of VIEWPORTS) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const { name, size } of VIEWPORTS) {
|
||||
for (const { name, use } of VIEWPORTS) {
|
||||
test.describe(`acessibilidade (axe), ${name}`, () => {
|
||||
test.use({ viewport: size });
|
||||
test.use(use);
|
||||
|
||||
test("nenhuma violação serious ou critical em nenhuma rota do sitemap", async ({
|
||||
page,
|
||||
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { DESKTOP, PHONE } from "./helpers";
|
||||
import { DESKTOP_USE, PHONE_USE } from "./helpers";
|
||||
|
||||
/**
|
||||
* (f) A home não tem trecho vazio, no computador (1440x900) e no celular (390x844): amostrada a cada ¼ de tela de
|
||||
@@ -14,8 +14,8 @@ import { DESKTOP, PHONE } from "./helpers";
|
||||
const STEP = 1 / 4;
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "computador 1440x900", size: DESKTOP },
|
||||
{ name: "celular 390x844", size: PHONE },
|
||||
{ name: "computador 1440x900", use: DESKTOP_USE },
|
||||
{ name: "celular 390x844 (isMobile, com toque)", use: PHONE_USE },
|
||||
] as const;
|
||||
|
||||
interface Sample {
|
||||
@@ -92,9 +92,9 @@ async function sampleHome(page: Page) {
|
||||
return { samples, step, maxScroll, viewportHeight };
|
||||
}
|
||||
|
||||
for (const { name, size } of VIEWPORTS) {
|
||||
for (const { name, use } of VIEWPORTS) {
|
||||
test.describe(`rolagem da home, ${name}`, () => {
|
||||
test.use({ viewport: size });
|
||||
test.use(use);
|
||||
|
||||
test("amostrada a cada ¼ de tela, nenhuma posição fica sem texto visível", async ({
|
||||
page,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { DESKTOP, PHONE } from "./helpers";
|
||||
import { DESKTOP_USE, PHONE_USE } from "./helpers";
|
||||
|
||||
/**
|
||||
* (j) O pôster da SEV Moda Íntima não se repete: em /servicos e em /cases ele aparece no máximo 2 vezes (meta da
|
||||
@@ -12,8 +12,8 @@ const SEV_ASSETS = "/videos/sev-modaintima/";
|
||||
const MAX_SEV_POSTERS = 2;
|
||||
const PAGES = ["/servicos", "/cases"] as const;
|
||||
const VIEWPORTS = [
|
||||
{ name: "computador 1440x900", size: DESKTOP },
|
||||
{ name: "celular 390x844", size: PHONE },
|
||||
{ name: "computador 1440x900", use: DESKTOP_USE },
|
||||
{ name: "celular 390x844 (isMobile, com toque)", use: PHONE_USE },
|
||||
] as const;
|
||||
|
||||
/** Elementos que mostram um arquivo de /videos/sev-modaintima/, descritos em texto. */
|
||||
@@ -46,9 +46,9 @@ function sevPosters(page: Page) {
|
||||
}, SEV_ASSETS);
|
||||
}
|
||||
|
||||
for (const { name, size } of VIEWPORTS) {
|
||||
for (const { name, use } of VIEWPORTS) {
|
||||
test.describe(`pôster da SEV, ${name}`, () => {
|
||||
test.use({ viewport: size });
|
||||
test.use(use);
|
||||
|
||||
// o detector enxerga o pôster onde ele certamente está (o hero da home e o próprio case): sem isso, "0 imagens"
|
||||
// poderia ser só um seletor que não acha nada
|
||||
|
||||
Reference in new issue
Block a user