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:
1 parent
6ac87824f3
commit
1a60795107
57 files changed
+625
-12090
No files matched your search
+75
-38
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user