integration: add Playwright, axe and Lighthouse CI gates

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>
This commit is contained in:
2026-08-25 18:28:24 +02:00
parent 46c86447d1
commit 34367181d2
16 changed files with 3715 additions and 22 deletions

48
e2e/a11y.e2e.ts Normal file
View File

@@ -0,0 +1,48 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import { pagePath } from './helpers';
const AXE_PATHS = [
pagePath('home', 'de'),
pagePath('home', 'en'),
pagePath('projects', 'de'),
pagePath('projects', 'en'),
pagePath('servicesAi', 'de'),
pagePath('servicesAi', 'en'),
pagePath('contact', 'de'),
pagePath('contact', 'en'),
pagePath('imprint', 'de'),
pagePath('imprint', 'en'),
'/missing-route',
'/en/missing-route',
];
test.describe('accessibility', () => {
test('revealed content stays visible under reduced motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(pagePath('home', 'de'));
const pending = page.locator('.reveal-pending');
await expect(pending).toHaveCount(0);
await expect(page.locator('app-systems-map')).toBeVisible();
await expect(page.locator('app-case-card').first()).toBeVisible();
});
for (const path of AXE_PATHS) {
test(`axe has no serious or critical violations on ${path}`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page }).analyze();
const blocking = results.violations.filter(
(violation) => violation.impact === 'serious' || violation.impact === 'critical',
);
const details = blocking
.map((violation) => {
const nodes = violation.nodes.map((node) => node.target.join(' ')).join(', ');
return `${violation.id} (${violation.impact}): ${nodes}`;
})
.join('\n');
expect(blocking, details).toEqual([]);
});
}
});

33
e2e/contact.e2e.ts Normal file
View File

@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { SITE_CONFIG } from '../src/app/core/content/site-config';
import { pagePath } from './helpers';
test.describe('contact briefing', () => {
test('builds a mailto href and issues no network request', async ({ page }) => {
const extras: string[] = [];
await page.route('**/*', (route) => {
const url = route.request().url();
const resource = route.request().resourceType();
if (resource !== 'document' && !url.startsWith('data:') && !url.startsWith('blob:')) {
extras.push(`${resource} ${url}`);
}
void route.continue();
});
await page.goto(pagePath('contact', 'de'), { waitUntil: 'networkidle' });
extras.length = 0;
await page.locator('#contact-name').fill('Ada');
await page.locator('#contact-email').fill('ada@example.com');
await page.locator('#contact-projectType').selectOption('software');
await page.locator('#contact-situation').fill('Need a migration.');
const href = await page.locator('a.submit').getAttribute('href');
expect(href).toMatch(/^mailto:/);
expect(href).toContain(encodeURIComponent(SITE_CONTENT_DATA.de.contact.mailSubject));
expect(href).toContain(encodeURIComponent('Ada'));
expect(href).toContain(SITE_CONFIG.contactEmail);
expect(extras, extras.join('\n')).toEqual([]);
});
});

92
e2e/helpers.ts Normal file
View File

@@ -0,0 +1,92 @@
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');
}

56
e2e/keyboard.e2e.ts Normal file
View File

@@ -0,0 +1,56 @@
import { expect, test } from '@playwright/test';
test.describe('keyboard and palette', () => {
test('skip link is first and moves focus to main', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const skip = page.locator('.skip-link');
await expect(skip).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('#main-content')).toBeFocused();
});
test('mobile nav toggle keeps aria-expanded in sync', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
const toggle = page.locator('.nav-toggle');
await expect(toggle).toBeVisible();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
});
test('palette opens with Control+K, traps focus, locks scroll and restores on Escape', async ({
page,
}) => {
await page.goto('/');
const trigger = page.locator('.command-palette-trigger');
await trigger.focus();
await page.keyboard.press('Control+k');
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible();
await expect(page.locator('.site')).toHaveAttribute('inert', '');
expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden');
const focusable = dialog.locator(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])',
);
const count = await focusable.count();
const first = focusable.first();
const last = focusable.nth(count - 1);
await last.focus();
await page.keyboard.press('Tab');
await expect(first).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(last).toBeFocused();
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(page.locator('.site')).not.toHaveAttribute('inert');
expect(await page.evaluate(() => document.body.style.overflow)).toBe('');
await expect(trigger).toBeFocused();
});
});

71
e2e/layout.e2e.ts Normal file
View File

@@ -0,0 +1,71 @@
import { expect, test } from '@playwright/test';
import { SITE_CONFIG } from '../src/app/core/content/site-config';
import { expectNoOverflow, pagePath } from './helpers';
const CHECKED = [
pagePath('home', 'de'),
pagePath('home', 'en'),
pagePath('projects', 'de'),
pagePath('projects', 'en'),
pagePath('servicesAi', 'de'),
pagePath('contact', 'de'),
pagePath('imprint', 'en'),
'/missing-route',
];
test.describe('layout and crawlability', () => {
test('does not overflow horizontally on checked routes', async ({ page }) => {
for (const path of CHECKED) {
await page.goto(path);
await expectNoOverflow(page);
}
});
test('same-origin links and crawl files respond below 400', async ({ page, request }) => {
const seen = new Set<string>();
const queue = [pagePath('home', 'de'), pagePath('home', 'en')];
while (queue.length > 0) {
const path = queue.shift() as string;
if (seen.has(path)) {
continue;
}
seen.add(path);
await page.goto(path);
const urls = await page.evaluate(() => {
const values = [
...Array.from(document.querySelectorAll('a[href]'), (node) => node.getAttribute('href')),
...Array.from(document.querySelectorAll('[src]'), (node) => node.getAttribute('src')),
];
return values.filter(
(value): value is string => typeof value === 'string' && value.length > 0,
);
});
for (const raw of urls) {
if (raw.startsWith('mailto:') || raw.startsWith('tel:') || raw.startsWith('#')) {
continue;
}
const resolved = new URL(raw, page.url());
if (resolved.origin !== new URL(page.url()).origin) {
continue;
}
const next = `${resolved.pathname}${resolved.search}`;
const response = await request.get(next);
expect(response.status(), `${raw} from ${path}`).toBeLessThan(400);
if (!seen.has(next) && !next.includes('.')) {
queue.push(next);
}
}
}
for (const asset of ['/robots.txt', '/sitemap.xml', '/llms.txt', SITE_CONFIG.cvAssetPath]) {
const response = await request.get(asset);
expect(response.status(), asset).toBe(200);
}
});
});

74
e2e/seo.e2e.ts Normal file
View File

@@ -0,0 +1,74 @@
import { expect, test } from '@playwright/test';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { expectHead, jsonLdGraph, pagePath } from './helpers';
test.describe('server-rendered metadata', () => {
test('German and English Home include the full head contract', async ({ request, page }) => {
await expectHead(request, 'home', 'de');
await expectHead(request, 'home', 'en');
await page.goto('/');
const graph = (await jsonLdGraph(page)) as { '@graph': Array<{ '@type': string }> };
const types = graph['@graph'].map((node) => node['@type']);
expect(types).toContain('Person');
expect(types).toContain('ProfessionalService');
await page.goto('/en');
const english = (await jsonLdGraph(page)) as { '@graph': Array<{ '@type': string }> };
expect(english['@graph'].map((node) => node['@type'])).toEqual(
expect.arrayContaining(['Person', 'ProfessionalService']),
);
});
test('projects, AI service, contact, legal and 404 keep locale metadata', async ({ request }) => {
for (const locale of ['de', 'en'] as const) {
await expectHead(request, 'projects', locale);
await expectHead(request, 'servicesAi', locale);
await expectHead(request, 'contact', locale);
await expectHead(request, 'imprint', locale);
await expectHead(request, 'notFound', locale);
}
});
test('client navigation does not duplicate head tags', async ({ page }) => {
await page.goto('/');
await page.locator('a.contact-cta').first().waitFor();
const assertUnique = async (canonical: string, description: string) => {
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
await expect(page.locator('link[rel="alternate"][hreflang="de-DE"]')).toHaveCount(1);
await expect(page.locator('link[rel="alternate"][hreflang="en"]')).toHaveCount(1);
await expect(page.locator('link[rel="alternate"][hreflang="x-default"]')).toHaveCount(1);
await expect(page.locator('meta[name="description"]')).toHaveCount(1);
await expect(page.locator('meta[name="robots"]')).toHaveCount(1);
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(1);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', canonical);
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
'content',
description,
);
};
await page
.locator(`a[href="${pagePath('projects', 'de')}"]`)
.first()
.click();
await page.waitForURL('**/projekte');
await assertUnique(
'https://antoniolede.de/projekte',
SITE_CONTENT_DATA.de.pages.projects.description,
);
await page
.locator(`a[href="${pagePath('servicesAi', 'de')}"]`)
.first()
.click();
await page.waitForURL('**/leistungen/ai-integration');
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
'https://antoniolede.de/leistungen/ai-integration',
);
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(1);
});
});

66
e2e/systems-map.e2e.ts Normal file
View File

@@ -0,0 +1,66 @@
import { expect, test } from '@playwright/test';
import { pagePath } from './helpers';
test.describe('Systems Map', () => {
test('keeps the card list visible and shows the SVG from 1024px', async ({ page }) => {
await page.goto(pagePath('home', 'de'));
for (const width of [390, 768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
const list = page.locator('.systems-map-list');
await expect(list).toBeVisible();
await expect(list.locator('.systems-map-cards li')).toHaveCount(8);
const figure = page.locator('.systems-map-figure');
if (width < 1024) {
await expect(figure).toBeHidden();
} else {
await expect(figure).toBeVisible();
}
}
});
test('keeps SVG labels inside their shapes at 1024 and 1440', async ({ page }) => {
await page.goto(pagePath('home', 'de'));
for (const width of [1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
const overflow = await page.evaluate(() => {
const nodes = Array.from(document.querySelectorAll<SVGGElement>('.systems-map-node'));
return nodes.flatMap((node) => {
const shape = node.querySelector('circle, rect');
const text = node.querySelector('text');
if (!shape || !text) {
return [`${node.getAttribute('aria-label') ?? 'node'} missing shape or text`];
}
const shapeBox = (shape as SVGGraphicsElement).getBBox();
const textBox = (text as SVGGraphicsElement).getBBox();
const fits =
textBox.x >= shapeBox.x - 0.5 &&
textBox.y >= shapeBox.y - 0.5 &&
textBox.x + textBox.width <= shapeBox.x + shapeBox.width + 0.5 &&
textBox.y + textBox.height <= shapeBox.y + shapeBox.height + 0.5;
return fits ? [] : [node.getAttribute('aria-label') ?? 'unnamed node'];
});
});
expect(overflow, `labels overflow at ${width}px`).toEqual([]);
}
});
test('every SVG node points at a real route', async ({ page, request }) => {
await page.goto(pagePath('home', 'de'));
await page.setViewportSize({ width: 1440, height: 900 });
const hrefs = await page
.locator('.systems-map-node')
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('href') ?? ''));
expect(hrefs.length).toBeGreaterThan(0);
for (const href of hrefs) {
const response = await request.get(href);
expect(response.status(), href).toBe(200);
}
});
});