Lock SEO, accessibility and crawlability with headless browser checks so regressions fail before a merge instead of after publication. Co-authored-by: Cursor <cursoragent@cursor.com>
93 lines
3.6 KiB
TypeScript
93 lines
3.6 KiB
TypeScript
import { expect, type APIRequestContext, type Page } from '@playwright/test';
|
|
import { SITE_CONFIG } from '../src/app/core/content/site-config';
|
|
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
|
|
import { type AppLocale } from '../src/app/core/i18n/locale';
|
|
import { LOCALE_HTML_LANG } from '../src/app/core/i18n/locale';
|
|
import { type RouteId } from '../src/app/core/routing/route-ids';
|
|
import { routePath } from '../src/app/core/routing/route-paths';
|
|
import { buildRouteMetadata } from '../src/app/core/seo/route-metadata';
|
|
|
|
export const ORIGIN = SITE_CONFIG.siteOrigin;
|
|
|
|
export function pagePath(routeId: RouteId, locale: AppLocale): string {
|
|
return routePath(routeId, locale);
|
|
}
|
|
|
|
export async function readHtml(request: APIRequestContext, path: string): Promise<string> {
|
|
const response = await request.get(path);
|
|
expect(response.status(), `GET ${path} failed`).toBeLessThan(400);
|
|
return response.text();
|
|
}
|
|
|
|
export function attr(html: string, selector: string, attribute: string): string | null {
|
|
const pattern = new RegExp(
|
|
`<[^>]*${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^>]*${attribute}="([^"]*)"`,
|
|
'i',
|
|
);
|
|
return html.match(pattern)?.[1] ?? null;
|
|
}
|
|
|
|
export function count(html: string, snippet: string): number {
|
|
return html.split(snippet).length - 1;
|
|
}
|
|
|
|
export async function expectHead(
|
|
request: APIRequestContext,
|
|
routeId: RouteId,
|
|
locale: AppLocale,
|
|
): Promise<void> {
|
|
const path =
|
|
routeId === 'notFound'
|
|
? locale === 'en'
|
|
? '/en/missing-route'
|
|
: '/missing-route'
|
|
: pagePath(routeId, locale);
|
|
const html = await readHtml(request, path);
|
|
const metadata = buildRouteMetadata(routeId, locale, SITE_CONTENT_DATA);
|
|
|
|
expect(html, `${path} title`).toContain(`<title>${metadata.title}</title>`);
|
|
expect(html).toContain(`name="description"`);
|
|
expect(html).toContain(`content="${metadata.description}"`);
|
|
expect(html).toContain(`name="robots"`);
|
|
expect(html).toContain(`content="${metadata.robots}"`);
|
|
expect(html).toContain(`<html lang="${LOCALE_HTML_LANG[locale]}"`);
|
|
expect(count(html, 'name="description"')).toBe(1);
|
|
expect(count(html, 'name="robots"')).toBe(1);
|
|
expect(count(html, 'type="application/ld+json"')).toBe(1);
|
|
|
|
if (routeId === 'notFound') {
|
|
expect(html).not.toMatch(/rel="canonical"/);
|
|
expect(html).not.toMatch(/rel="alternate"[^>]*hreflang/);
|
|
return;
|
|
}
|
|
|
|
expect(html).toContain(`rel="canonical"`);
|
|
expect(html).toContain(`href="${metadata.canonical}"`);
|
|
expect(count(html, 'rel="canonical"')).toBe(1);
|
|
expect(html).toContain('hreflang="de-DE"');
|
|
expect(html).toContain('hreflang="en"');
|
|
expect(html).toContain('hreflang="x-default"');
|
|
expect(html).toContain('property="og:type"');
|
|
expect(html).toContain('property="og:title"');
|
|
expect(html).toContain('property="og:description"');
|
|
expect(html).toContain('property="og:url"');
|
|
expect(html).toContain('property="og:site_name"');
|
|
expect(html).toContain('property="og:locale"');
|
|
expect(html).toContain('name="twitter:card"');
|
|
expect(html).toContain('name="twitter:title"');
|
|
expect(html).toContain('name="twitter:description"');
|
|
}
|
|
|
|
export async function expectNoOverflow(page: Page): Promise<void> {
|
|
const overflow = await page.evaluate(
|
|
() => document.documentElement.scrollWidth <= window.innerWidth + 1,
|
|
);
|
|
expect(overflow, `horizontal overflow at ${page.url()}`).toBe(true);
|
|
}
|
|
|
|
export async function jsonLdGraph(page: Page): Promise<unknown> {
|
|
const raw = await page.locator('script[type="application/ld+json"]').first().textContent();
|
|
expect(raw).toBeTruthy();
|
|
return JSON.parse(raw ?? 'null');
|
|
}
|