chore: testes de ponta a ponta no CI e limpeza do repositório

Job e2e separado no CI; testes de teclado, rolagem, sem JS, axe no celular e menu do celular; limites de metadados derivados do sitemap; sai @react-three/fiber e @react-three/drei; pastas de ferramentas (.augment, .kiro, .claude/settings.local.json) fora do Git.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 17:01:37 -03:00
1 parent 6ac87824f3
commit 1a60795107
57 files changed
+625 -12090

No files matched your search

+75 -38
View File
@@ -1,13 +1,10 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import type { Metadata } from "next";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
import { generateMetadata as generateCaseMetadata } from "./cases/[slug]/page";
import { metadata as casesMetadata } from "./cases/page";
import { metadata as curriculoMetadata } from "./curriculo/page";
import { siteUrl } from "@/lib/site-content";
import { metadata as layoutMetadata } from "./layout";
import { metadata as notFoundMetadata } from "./not-found";
import { metadata as privacyMetadata } from "./privacidade/page";
import { generateMetadata as generateServiceMetadata } from "./servicos/[slug]/page";
import { metadata as servicesMetadata } from "./servicos/page";
import sitemap from "./sitemap";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
// o layout importa a fonte do Google e o CSS global; fora do Next nenhum dos dois existe
@@ -28,33 +25,58 @@ interface Route {
metadata: Metadata;
}
async function collectRoutes(): Promise<Route[]> {
const cases = await Promise.all(
caseStudies.map(async ({ id }) => ({
route: `/cases/${id}`,
metadata: await generateCaseMetadata({
params: Promise.resolve({ slug: id }),
}),
})),
);
const services = await Promise.all(
servicePages.map(async ({ slug }) => ({
route: `/servicos/${slug}`,
metadata: await generateServiceMetadata({
params: Promise.resolve({ slug }),
}),
})),
);
/** Caminhos (`/cases/…`, `/ia`) de todas as URLs de app/sitemap.ts, menos a home, que tem o bloco próprio mais abaixo. */
function sitemapRoutes() {
return sitemap()
.map(({ url }) => new URL(url).pathname)
.filter((route) => route !== "/");
}
return [
{ route: "/cases", metadata: casesMetadata },
...cases,
{ route: "/servicos", metadata: servicesMetadata },
...services,
{ route: "/curriculo", metadata: curriculoMetadata },
{ route: "/privacidade", metadata: privacyMetadata },
{ route: "/not-found", metadata: notFoundMetadata },
];
/** Acha app/<rota>/page: um trecho sem pasta própria cai na pasta de segmento dinâmico (`[slug]`), e vira parâmetro. */
function resolvePage(route: string) {
let dir = __dirname;
const params: Record<string, string> = {};
for (const segment of route.split("/").filter(Boolean)) {
if (existsSync(join(dir, segment))) {
dir = join(dir, segment);
continue;
}
const dynamic = readdirSync(dir).find((name) => /^\[\w+\]$/.test(name));
if (!dynamic)
throw new Error(`${route}: sem pasta em app/ para "${segment}"`);
params[dynamic.slice(1, -1)] = segment;
dir = join(dir, dynamic);
}
return { file: join(dir, "page"), params };
}
interface PageModule {
metadata?: Metadata;
generateMetadata?: (props: {
params: Promise<Record<string, string>>;
}) => Promise<Metadata>;
}
async function metadataOf(route: string): Promise<Metadata> {
const { file, params } = resolvePage(route);
const page = (await import(file)) as PageModule;
if (page.generateMetadata)
return page.generateMetadata({ params: Promise.resolve(params) });
if (page.metadata) return page.metadata;
throw new Error(
`${route}: a página não exporta metadata nem generateMetadata`,
);
}
/** Todas as rotas do sitemap (menos a home) e o 404, que não entra no sitemap. */
async function collectRoutes(): Promise<Route[]> {
const pages = await Promise.all(
sitemapRoutes().map(async (route) => ({
route,
metadata: await metadataOf(route),
})),
);
return [...pages, { route: "/not-found", metadata: notFoundMetadata }];
}
function textOf(value: unknown): string {
@@ -62,12 +84,22 @@ function textOf(value: unknown): string {
throw new Error(`Esperava um texto, recebi ${JSON.stringify(value)}`);
}
describe("títulos e descrições das rotas de cases, serviços, currículo, privacidade e 404", () => {
it("cobre as 19 rotas do escopo", async () => {
expect(await collectRoutes()).toHaveLength(1 + 8 + 1 + 6 + 1 + 1 + 1);
describe("títulos e descrições das rotas do sitemap (menos a home) e do 404", () => {
it("cobre cada rota do sitemap, incluindo /ia, e o 404", async () => {
const routes = (await collectRoutes()).map(({ route }) => route);
expect(sitemapRoutes().length).toBeGreaterThan(15);
expect(routes).toEqual([...sitemapRoutes(), "/not-found"]);
expect(routes).toContain("/ia");
expect(TITLE_SUFFIX).toHaveLength(17);
});
it(`mantém a descrição de /ia em até ${MAX_DESCRIPTION} caracteres`, async () => {
const ia = (await collectRoutes()).find(({ route }) => route === "/ia");
const description = textOf(ia?.metadata.description);
expect(description.length).toBeLessThanOrEqual(MAX_DESCRIPTION);
expect(description.length).toBeGreaterThanOrEqual(40);
});
it(`mantém o título em até ${MAX_META_TITLE} caracteres, ${MAX_FULL_TITLE} com o sufixo do layout`, async () => {
const violations = (await collectRoutes()).flatMap(
({ route, metadata }) => {
@@ -103,12 +135,17 @@ describe("títulos e descrições das rotas de cases, serviços, currículo, pri
expect(violations).toEqual([]);
});
it("repete no cartão de compartilhamento o título e a descrição, dentro dos mesmos limites", async () => {
it("repete no cartão de compartilhamento o título (com ou sem o sufixo do site) e a descrição, dentro dos mesmos limites", async () => {
for (const { route, metadata } of await collectRoutes()) {
// o 404 não tem cartão próprio: só noindex
if (route === "/not-found") continue;
const title = textOf(metadata.title);
for (const card of [metadata.openGraph, metadata.twitter]) {
expect(textOf(card?.title)).toBe(textOf(metadata.title));
// o cartão não passa pelo template do layout: a página pode pôr o sufixo na mão (/ia faz isso)
expect([title, `${title}${TITLE_SUFFIX}`]).toContain(
textOf(card?.title),
);
expect(textOf(card?.title).length).toBeLessThanOrEqual(MAX_FULL_TITLE);
expect(textOf(card?.description)).toBe(textOf(metadata.description));
}
}