test: e2e mais rígida e .gitattributes
Zero telas sem texto no computador e no celular; atalho de teclado em até 4 Tabs e linear em até 36 com o inventário das paradas; ciclo de foco do menu com a barra de cookies; oferta e cabeçalho levando ao formulário; foco vindo de outra página; separador da faixa de clientes; contagem de pôsteres da SEV. .gitattributes com eol=lf.
This commit is contained in:
1 parent
204ebd5fa5
commit
2cd4a98c55
10 files changed
+588
-87
No files matched your search
@@ -0,0 +1,11 @@
|
||||
# Texto sempre com LF no repositório e na cópia de trabalho (o Windows com core.autocrlf=true gerava CRLF e o Biome acusava)
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binários: nunca converter quebras de linha
|
||||
*.mp4 binary
|
||||
*.webm binary
|
||||
*.webp binary
|
||||
*.jpg binary
|
||||
*.png binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { clientProjectNames } from "./helpers";
|
||||
|
||||
/**
|
||||
* (i) Faixa "Já entregue para" do hero (ClientStrip), em larguras de celular a computador: o ponto separador (·) só
|
||||
* aparece ENTRE itens da mesma linha. O ponto abre cada item (menos o primeiro) e a margem negativa o esconde, pelo
|
||||
* `overflow-x: clip` da lista, quando o item abre uma linha nova. Então, para cada item depois do primeiro:
|
||||
* ponto visível <=> o item está na mesma linha do anterior.
|
||||
* Nenhuma linha começa nem termina com "·". O Jest só confere as classes; só o navegador mostra o corte.
|
||||
* "Visível" = o centro do ponto está dentro da caixa da lista (o que o corte deixa passar).
|
||||
*/
|
||||
|
||||
const WIDTHS = [320, 360, 390, 430, 560, 768, 1024, 1440];
|
||||
|
||||
async function stripItems(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const label = Array.from(document.querySelectorAll("p")).find(
|
||||
(paragraph) => paragraph.textContent?.trim() === "Já entregue para:",
|
||||
);
|
||||
const list = label?.parentElement?.querySelector("ul");
|
||||
if (!list) return null;
|
||||
const listBox = list.getBoundingClientRect();
|
||||
const items = Array.from(list.querySelectorAll("li")).map((item) => {
|
||||
const dot = item.querySelector("[aria-hidden='true']");
|
||||
const dotBox = dot?.getBoundingClientRect();
|
||||
return {
|
||||
text: (item.textContent ?? "").replace(/\s+/g, " ").trim(),
|
||||
// o centro vertical: um item mais baixo (os anos) fica centrado na linha e tem outro topo
|
||||
center: Math.round(
|
||||
item.getBoundingClientRect().top +
|
||||
item.getBoundingClientRect().height / 2,
|
||||
),
|
||||
hasDot: Boolean(dot),
|
||||
dotCenter: dotBox ? dotBox.left + dotBox.width / 2 : null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
items,
|
||||
listLeft: listBox.left,
|
||||
listRight: listBox.right,
|
||||
overflowX: getComputedStyle(list).overflowX,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Confere a regra "ponto visível <=> mesma linha do anterior" na faixa como ela está agora. Devolve quantas quebras há. */
|
||||
async function expectDotsOnlyBetweenItemsOfOneLine(page: Page, where: string) {
|
||||
const strip = await stripItems(page);
|
||||
expect(strip, `${where}: a faixa 'Já entregue para:' no hero`).not.toBeNull();
|
||||
if (!strip) return 0;
|
||||
const { items, listLeft, listRight, overflowX } = strip;
|
||||
// 4 clientes + os anos de experiência
|
||||
expect(items.length, `${where}: itens da faixa`).toBeGreaterThanOrEqual(
|
||||
clientProjectNames().length,
|
||||
);
|
||||
expect(overflowX, `${where}: o corte do ponto depende de overflow-x`).toBe(
|
||||
"clip",
|
||||
);
|
||||
expect(items[0].hasDot, `${where}: o primeiro item não tem ponto`).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const report = items
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.center}px ${item.hasDot ? `ponto em ${Math.round(item.dotCenter ?? 0)}` : "sem ponto"} "${item.text}"`,
|
||||
)
|
||||
.join("\n");
|
||||
let wraps = 0;
|
||||
for (let index = 1; index < items.length; index++) {
|
||||
const item = items[index];
|
||||
const sameLine = Math.abs(item.center - items[index - 1].center) < 12;
|
||||
if (!sameLine) wraps += 1;
|
||||
const visible =
|
||||
item.dotCenter !== null &&
|
||||
item.dotCenter >= listLeft &&
|
||||
item.dotCenter <= listRight;
|
||||
expect(
|
||||
visible,
|
||||
`${where}, "${item.text}": ${sameLine ? "na mesma linha do anterior, o ponto precisa aparecer" : "abre uma linha nova, o ponto precisa ficar escondido"} (lista de ${Math.round(listLeft)} a ${Math.round(listRight)}px)\n${report}`,
|
||||
).toBe(sameLine);
|
||||
}
|
||||
return wraps;
|
||||
}
|
||||
|
||||
for (const width of WIDTHS) {
|
||||
test(`faixa de clientes a ${width}px: o ponto só aparece entre itens da mesma linha`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto("/", { waitUntil: "load" });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await expectDotsOnlyBetweenItemsOfOneLine(page, `${width}px`);
|
||||
});
|
||||
}
|
||||
|
||||
test("faixa de clientes em colunas estreitas (quebra forçada): nenhuma linha começa nem termina com o ponto", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/", { waitUntil: "load" });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
let wrappedAtLeastOnce = false;
|
||||
for (const columnWidth of [140, 200, 260, 320, 380, 440, 520, 600]) {
|
||||
await page.evaluate((px) => {
|
||||
const label = Array.from(document.querySelectorAll("p")).find(
|
||||
(paragraph) => paragraph.textContent?.trim() === "Já entregue para:",
|
||||
);
|
||||
const wrapper = label?.parentElement;
|
||||
if (wrapper) wrapper.style.maxWidth = `${px}px`;
|
||||
}, columnWidth);
|
||||
const wraps = await expectDotsOnlyBetweenItemsOfOneLine(
|
||||
page,
|
||||
`coluna de ${columnWidth}px`,
|
||||
);
|
||||
if (wraps > 0) wrappedAtLeastOnce = true;
|
||||
}
|
||||
expect(wrappedAtLeastOnce, "alguma largura quebrou a faixa em linhas").toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { DESKTOP } 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);
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
const CTA_LABEL = "Falar sobre meu projeto";
|
||||
|
||||
/**
|
||||
* Tipo de cada oferta (o `?tipo=` do href do botão) -> opção que o formulário mostra marcada. Espelha CONTACT_KINDS
|
||||
* (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",
|
||||
} as const;
|
||||
|
||||
test.use({ viewport: DESKTOP });
|
||||
|
||||
/** 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) {
|
||||
await page.goto(path, { waitUntil: "load" });
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
const headerContact = (page: Page) =>
|
||||
page.locator("header").getByRole("link", { name: CTA_LABEL });
|
||||
|
||||
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(() => {
|
||||
const box = (selector: string) => {
|
||||
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
||||
return rect
|
||||
? { top: Math.round(rect.top), bottom: Math.round(rect.bottom) }
|
||||
: null;
|
||||
};
|
||||
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']"),
|
||||
};
|
||||
const hidden = Object.entries(parts).filter(
|
||||
([, rect]) =>
|
||||
!rect || rect.top < headerBottom - 1 || rect.bottom > innerHeight + 1,
|
||||
);
|
||||
return hidden.length === 0
|
||||
? "ok"
|
||||
: `fora da tela: ${hidden.map(([name]) => name).join(", ")}; ${JSON.stringify({ ...parts, headerBottom, janela: innerHeight, rolagem: Math.round(scrollY) })}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Espera a rolagem (suave ou não) levar o formulário de contato para a tela. */
|
||||
async function expectContactOnScreen(page: Page, what: string) {
|
||||
await expect
|
||||
.poll(() => contactFormOnScreen(page), {
|
||||
message: `${what}: o formulário de contato na tela`,
|
||||
timeout: 8_000,
|
||||
})
|
||||
.toBe("ok");
|
||||
}
|
||||
|
||||
test("a home tem as três ofertas e cada uma tem o seu tipo de projeto", async ({
|
||||
page,
|
||||
}) => {
|
||||
await open(page, "/");
|
||||
const hrefs = await page
|
||||
.locator("#modalities a[href*='?tipo=']")
|
||||
.evaluateAll((links) => links.map((link) => link.getAttribute("href")));
|
||||
expect(hrefs.sort()).toEqual(
|
||||
Object.keys(SUBJECT_BY_KIND)
|
||||
.map((kind) => `/?tipo=${kind}#contact`)
|
||||
.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 ({
|
||||
page,
|
||||
}) => {
|
||||
await open(page, "/");
|
||||
const offer = page.locator(`#modalities a[href='/?tipo=${kind}#contact']`);
|
||||
await offer.scrollIntoViewIfNeeded();
|
||||
await offer.click();
|
||||
|
||||
await expectContactOnScreen(page, `depois da oferta "${kind}"`);
|
||||
await expect(page.locator("#contact select[name='subject']")).toHaveValue(
|
||||
subject,
|
||||
);
|
||||
|
||||
// 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);
|
||||
await expect.poll(() => contactFormOnScreen(page)).not.toBe("ok");
|
||||
await headerContact(page).click();
|
||||
await expectContactOnScreen(page, "depois do clique no cabeçalho");
|
||||
// e o tipo escolhido pela oferta continua marcado
|
||||
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,
|
||||
}) => {
|
||||
await open(page, "/privacidade");
|
||||
await headerContact(page).click();
|
||||
await page.waitForURL(/\/#contact$/);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const contact = document.getElementById("contact");
|
||||
const active = document.activeElement;
|
||||
return contact !== null && active !== null && contact.contains(active)
|
||||
? "dentro de #contact"
|
||||
: `fora de #contact: ${active?.tagName.toLowerCase()}${active?.id ? `#${active.id}` : ""}`;
|
||||
}),
|
||||
{ message: "foco depois de chegar a /#contact", timeout: 8_000 },
|
||||
)
|
||||
.toBe("dentro de #contact");
|
||||
});
|
||||
|
||||
test.describe("com movimento reduzido", () => {
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
test("na home, dois cliques seguidos no link /#contact do cabeçalho rolam nos dois", async ({
|
||||
page,
|
||||
}) => {
|
||||
await open(page, "/");
|
||||
for (const click of ["primeiro", "segundo"]) {
|
||||
await scrollToTop(page);
|
||||
await expect.poll(() => contactFormOnScreen(page)).not.toBe("ok");
|
||||
await headerContact(page).click();
|
||||
await expectContactOnScreen(page, `${click} clique`);
|
||||
}
|
||||
});
|
||||
|
||||
test("chegando de /privacidade, o clique no cabeçalho e um segundo clique na home rolam nos dois", async ({
|
||||
page,
|
||||
}) => {
|
||||
await open(page, "/privacidade");
|
||||
await headerContact(page).click();
|
||||
await page.waitForURL(/\/#contact$/);
|
||||
await expectContactOnScreen(page, "primeiro clique (de /privacidade)");
|
||||
|
||||
await scrollToTop(page);
|
||||
await expect.poll(() => contactFormOnScreen(page)).not.toBe("ok");
|
||||
await headerContact(page).click();
|
||||
await expectContactOnScreen(page, "segundo clique (já na home)");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,25 @@ export async function sitemapPaths(request: APIRequestContext) {
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
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).
|
||||
*/
|
||||
export function isDevServer(request: APIRequestContext) {
|
||||
devServer ??= request
|
||||
.get("/")
|
||||
.then((response) => response.text())
|
||||
.then((html) => /hmr-client|react-refresh/.test(html))
|
||||
.catch((error) => {
|
||||
devServer = undefined;
|
||||
throw error;
|
||||
});
|
||||
return devServer;
|
||||
}
|
||||
|
||||
/** Nomes dos projetos de cliente, como a home os lista (public/portfolio-data.json, kind "cliente"). */
|
||||
export function clientProjectNames() {
|
||||
const data = JSON.parse(
|
||||
|
||||
+14
-3
@@ -5,15 +5,26 @@ import { DESKTOP } 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é 40 toques
|
||||
* (a vitrine de projetos é uma parada só, navegada com as setas).
|
||||
* (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).
|
||||
* Os testes continuam apertando Tab depois do limite só para dizer, na falha, quantos toques faltavam.
|
||||
*/
|
||||
|
||||
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;
|
||||
/**
|
||||
* Limite do caminho linear: 36 toques. Inventário das paradas legítimas, do topo da home ao campo de nome (35 hoje):
|
||||
* 1 link "Pular para o conteúdo";
|
||||
* cabeçalho: logo, 5 links (Cases, Serviços, Como trabalho, Sobre, IA) e "Falar sobre meu projeto" (7);
|
||||
* hero: "Falar sobre meu projeto", "Ver projetos entregues", os 4 clientes da faixa, LinkedIn e e-mail (8);
|
||||
* vitrine de projetos: o seletor, o link do case do projeto aberto e "Ver os cases" (3);
|
||||
* ofertas: 3 botões e 3 links de case/página, mais "Ver todos os serviços" (7);
|
||||
* "Ver trajetória completa" (1) e as 5 perguntas da FAQ (5);
|
||||
* contato: WhatsApp e e-mail (2), e então o campo de nome.
|
||||
* 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;
|
||||
const LINEAR_GIVE_UP_AFTER = 120;
|
||||
|
||||
test.use({ viewport: DESKTOP });
|
||||
|
||||
+111
-39
@@ -1,29 +1,34 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { PHONE } from "./helpers";
|
||||
import { isDevServer, 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;
|
||||
* - o Tab fica preso no cabeçalho e na barra de cookies com o menu aberto, nos dois sentidos, e a volta passa pelos
|
||||
* três controles da barra: contato -> "Política de privacidade" -> Aceitar -> Recusar -> logo -> "Fechar menu";
|
||||
* - 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";
|
||||
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"];
|
||||
|
||||
/** `run` aparece em `sequence`, em posições seguidas? */
|
||||
function hasRun(sequence: string[], run: string[]) {
|
||||
return sequence.some((_, start) =>
|
||||
run.every((label, offset) => sequence[start + offset] === label),
|
||||
);
|
||||
}
|
||||
|
||||
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"))
|
||||
);
|
||||
});
|
||||
await waitUntilInteractive(page);
|
||||
});
|
||||
|
||||
const openMenuButton = (page: Page) =>
|
||||
@@ -33,6 +38,18 @@ const closeMenuButton = (page: Page) =>
|
||||
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.
|
||||
*/
|
||||
async function waitUntilInteractive(page: Page) {
|
||||
await expect(openMenuButton(page)).toBeVisible();
|
||||
await openMenu(page);
|
||||
await closeMenuButton(page).click();
|
||||
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
|
||||
@@ -48,6 +65,52 @@ 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,
|
||||
}) => {
|
||||
@@ -63,22 +126,7 @@ test("o menu abre pelo botão e mostra os links e o contato", async ({
|
||||
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");
|
||||
});
|
||||
}
|
||||
const bar = await cookieBar(page);
|
||||
await openMenu(page);
|
||||
await expect(bar).toBeVisible();
|
||||
|
||||
@@ -120,9 +168,10 @@ test("o botão de contato do menu não fica sob a barra de cookies", async ({
|
||||
}
|
||||
});
|
||||
|
||||
test("com o menu aberto o Tab e o Shift+Tab ficam presos no cabeçalho", async ({
|
||||
test("com o menu aberto o Tab e o Shift+Tab ficam presos no cabeçalho e na barra de cookies", async ({
|
||||
page,
|
||||
}) => {
|
||||
await cookieBar(page);
|
||||
await openMenu(page);
|
||||
|
||||
const header = page.locator("header");
|
||||
@@ -139,27 +188,50 @@ test("com o menu aberto o Tab e o Shift+Tab ficam presos no cabeçalho", async (
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 40);
|
||||
return {
|
||||
insideHeader: Boolean(element?.closest("header")),
|
||||
label: `${element?.tagName.toLowerCase()} "${label}"`,
|
||||
// o cabeçalho e a barra de cookies (id cookie-bar, COOKIE_BAR_ID) formam o ciclo do menu
|
||||
inside: Boolean(element?.closest("header, #cookie-bar")),
|
||||
label,
|
||||
};
|
||||
});
|
||||
|
||||
// o ciclo, na ordem do Tab: a barra (vem antes do cabeçalho no layout), logo, botão do menu, 5 links, contato
|
||||
const cycle = [
|
||||
...BAR_CONTROLS,
|
||||
LOGO_LABEL,
|
||||
CLOSE_LABEL,
|
||||
"Cases",
|
||||
"Serviços",
|
||||
"Como trabalho",
|
||||
"Sobre",
|
||||
"IA",
|
||||
CTA_LABEL,
|
||||
];
|
||||
// a volta que mais importa: do contato à barra de cookies, ao logo e ao botão do menu
|
||||
const wrap = [CTA_LABEL, ...BAR_CONTROLS, LOGO_LABEL, CLOSE_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++) {
|
||||
const sequence: string[] = [];
|
||||
// mais toques do que controles no ciclo: dá a volta inteira, mais de uma vez
|
||||
for (let press = 1; press <= cycle.length * 2; press++) {
|
||||
await page.keyboard.press(key);
|
||||
const { insideHeader, label } = await whereIsFocus();
|
||||
const { inside, label } = await whereIsFocus();
|
||||
expect(
|
||||
insideHeader,
|
||||
`${key} nº ${press} levou o foco para fora do cabeçalho: ${label}`,
|
||||
inside,
|
||||
`${key} nº ${press} levou o foco para fora do cabeçalho e da barra de cookies: ${label}`,
|
||||
).toBe(true);
|
||||
visited.add(label);
|
||||
sequence.push(label);
|
||||
}
|
||||
const visited = new Set(sequence);
|
||||
const missing = cycle.filter((label) => !visited.has(label));
|
||||
expect(
|
||||
visited.size,
|
||||
`${key} percorreu ${visited.size} controles: ${[...visited].join("; ")}`,
|
||||
).toBeGreaterThanOrEqual(8);
|
||||
missing,
|
||||
`${key} não passou por: ${missing.join("; ")}; passou por: ${[...visited].join("; ")}`,
|
||||
).toEqual([]);
|
||||
const expected = key === "Tab" ? wrap : [...wrap].reverse();
|
||||
expect(
|
||||
hasRun(sequence, expected),
|
||||
`${key} deveria andar ${expected.join(" -> ")}; andou ${sequence.join(" -> ")}`,
|
||||
).toBe(true);
|
||||
}
|
||||
await expect(header.getByRole("link", { name: CTA_LABEL })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -47,7 +47,10 @@ test("menu Cases e depois um case: sem recarregar o documento, uma transição d
|
||||
await page.waitForTimeout(500);
|
||||
expect(await transitions(), "depois de Cases").toBe(1);
|
||||
|
||||
await page.getByRole("link", { name: "Ver o case" }).first().click();
|
||||
await page
|
||||
.getByRole("link", { name: /^Ver case / })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL(/\/cases\/[^/]+$/);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
+23
-3
@@ -1,13 +1,22 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { DESKTOP, PHONE, sitemapPaths } from "./helpers";
|
||||
import { DESKTOP, isDevServer, 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, nos mesmos dois tamanhos.
|
||||
* As rotas vêm do próprio sitemap: uma página nova entra no teste sozinha.
|
||||
*
|
||||
* Erro de console é falha, sem exceção em produção. Única exceção, só quando o BASE_URL é um servidor de
|
||||
* desenvolvimento (`next dev`, detectado por isDevServer): o aviso do React "Each child in a list should have a
|
||||
* unique "key" prop", que só existe no React de desenvolvimento (o build de produção não o emite) e que a pilha
|
||||
* atribui a `OuterLayoutRouter`, do roteador do próprio Next, e não a um componente do site. Ele não é
|
||||
* esquecido: cada rota em que aparece fica anotada no relatório do teste ("aviso do next dev ignorado"). Qualquer
|
||||
* outro erro de console reprova nos dois modos.
|
||||
*/
|
||||
const DEV_ONLY_KEY_WARNING =
|
||||
/Each child in a list should have a unique "key" prop/;
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "computador 1440x900", size: DESKTOP },
|
||||
@@ -24,6 +33,8 @@ for (const { name, size } of VIEWPORTS) {
|
||||
}) => {
|
||||
const paths = await sitemapPaths(request);
|
||||
expect(paths.length, "rotas no sitemap").toBeGreaterThan(10);
|
||||
const dev = await isDevServer(request);
|
||||
const ignoredWarnings = new Set<string>();
|
||||
|
||||
for (const path of paths) {
|
||||
await test.step(path, async () => {
|
||||
@@ -31,8 +42,12 @@ for (const { name, size } of VIEWPORTS) {
|
||||
const onConsole = (
|
||||
message: import("@playwright/test").ConsoleMessage,
|
||||
) => {
|
||||
if (message.type() === "error")
|
||||
problems.push(`console.error: ${message.text()}`);
|
||||
if (message.type() !== "error") return;
|
||||
if (dev && DEV_ONLY_KEY_WARNING.test(message.text())) {
|
||||
ignoredWarnings.add(path);
|
||||
return;
|
||||
}
|
||||
problems.push(`console.error: ${message.text()}`);
|
||||
};
|
||||
const onPageError = (error: Error) =>
|
||||
problems.push(`erro não tratado: ${error.message}`);
|
||||
@@ -60,6 +75,11 @@ for (const { name, size } of VIEWPORTS) {
|
||||
.toBeLessThanOrEqual(0);
|
||||
});
|
||||
}
|
||||
for (const path of ignoredWarnings)
|
||||
test.info().annotations.push({
|
||||
type: "aviso do next dev ignorado",
|
||||
description: `${path}: Each child in a list should have a unique "key" prop`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+36
-41
@@ -1,20 +1,22 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { DESKTOP } from "./helpers";
|
||||
import { DESKTOP, PHONE } 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.
|
||||
* (f) A home não tem trecho vazio, no computador (1440x900) e no celular (390x844): amostrada a cada ¼ de tela de
|
||||
* rolagem, do topo ao fim, TODA amostra tem texto visível. Tolerância zero: nem uma amostra isolada sem texto passa
|
||||
* (uma troca de cena que deixe a tela vazia por um instante, no ponto exato de uma amostra, reprova e conta como
|
||||
* defeito da vitrine, não do teste).
|
||||
* 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 });
|
||||
const VIEWPORTS = [
|
||||
{ name: "computador 1440x900", size: DESKTOP },
|
||||
{ name: "celular 390x844", size: PHONE },
|
||||
] as const;
|
||||
|
||||
interface Sample {
|
||||
top: number;
|
||||
@@ -90,38 +92,31 @@ async function sampleHome(page: 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;
|
||||
for (const { name, size } of VIEWPORTS) {
|
||||
test.describe(`rolagem da home, ${name}`, () => {
|
||||
test.use({ viewport: size });
|
||||
|
||||
test("amostrada a cada ¼ de tela, nenhuma posição fica sem texto visível", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "load" });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const { samples, step, maxScroll, viewportHeight } =
|
||||
await sampleHome(page);
|
||||
const empty = samples
|
||||
.filter(({ count }) => count === 0)
|
||||
.map(
|
||||
({ top }) => `${top}px (${(top / viewportHeight).toFixed(2)} tela)`,
|
||||
);
|
||||
|
||||
const log = samples
|
||||
.map(({ top, count }) => `${top}px: ${count} trecho(s) de texto`)
|
||||
.join("\n");
|
||||
expect(
|
||||
empty,
|
||||
`posições sem texto visível (amostras a cada ${step}px, página de ${maxScroll}px, janela de ${viewportHeight}px):\n${log}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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([]);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { DESKTOP, PHONE } 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
|
||||
* rodada 4), contando todo elemento que o mostra ao navegador (<img>, <video poster>, fundo CSS) e que não esteja
|
||||
* dentro da própria página do case. Conta o que está no HTML, visível ou não, porque o que está no HTML é o que o
|
||||
* navegador baixa. O mesmo vale nas duas larguras.
|
||||
*/
|
||||
|
||||
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 },
|
||||
] as const;
|
||||
|
||||
/** Elementos que mostram um arquivo de /videos/sev-modaintima/, descritos em texto. */
|
||||
function sevPosters(page: Page) {
|
||||
return page.evaluate((assets) => {
|
||||
const decoded = (value: string | null) => {
|
||||
try {
|
||||
return decodeURIComponent(value ?? "");
|
||||
} catch {
|
||||
return value ?? "";
|
||||
}
|
||||
};
|
||||
const found: string[] = [];
|
||||
for (const element of document.querySelectorAll("*")) {
|
||||
const sources = [
|
||||
element.getAttribute("src"),
|
||||
element.getAttribute("srcset"),
|
||||
element.getAttribute("poster"),
|
||||
element instanceof HTMLElement ? element.style.backgroundImage : null,
|
||||
];
|
||||
if (element instanceof HTMLElement && element.tagName !== "HTML")
|
||||
sources.push(getComputedStyle(element).backgroundImage);
|
||||
const hit = sources.find((value) => decoded(value).includes(assets));
|
||||
if (hit)
|
||||
found.push(
|
||||
`<${element.tagName.toLowerCase()}> ${decoded(hit).slice(0, 110)}`,
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}, SEV_ASSETS);
|
||||
}
|
||||
|
||||
for (const { name, size } of VIEWPORTS) {
|
||||
test.describe(`pôster da SEV, ${name}`, () => {
|
||||
test.use({ viewport: size });
|
||||
|
||||
// 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
|
||||
for (const path of ["/", "/cases/sev-modaintima"]) {
|
||||
test(`${path}: o detector acha o pôster da SEV`, async ({ page }) => {
|
||||
await page.goto(path, { waitUntil: "load" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
expect((await sevPosters(page)).length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const path of PAGES) {
|
||||
test(`${path}: no máximo ${MAX_SEV_POSTERS} imagens da SEV fora do próprio case`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(path, { waitUntil: "load" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const posters = await sevPosters(page);
|
||||
|
||||
expect(
|
||||
posters.length,
|
||||
`${path}: ${posters.length} elemento(s) mostram arquivos da SEV (meta: no máximo ${MAX_SEV_POSTERS}):\n${posters.join("\n")}`,
|
||||
).toBeLessThanOrEqual(MAX_SEV_POSTERS);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user