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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -41,3 +41,9 @@ __screenshots__/
|
|||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Browser and audit artifacts
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
/blob-report
|
||||||
|
/.lighthouseci
|
||||||
|
|||||||
@@ -8,3 +8,7 @@ public/**/*.svg
|
|||||||
Dockerfile
|
Dockerfile
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
.cursor
|
.cursor
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
.lighthouseci
|
||||||
|
blob-report
|
||||||
|
|||||||
13
AGENTS.md
13
AGENTS.md
@@ -42,6 +42,9 @@ This is a bilingual recruiter and B2B portfolio for a software/DevOps engineer.
|
|||||||
| `npm run format:check` | Prettier check (CI). |
|
| `npm run format:check` | Prettier check (CI). |
|
||||||
| `npm run serve:ssr` | Serve the production SSR bundle. |
|
| `npm run serve:ssr` | Serve the production SSR bundle. |
|
||||||
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr` for existing tooling. |
|
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr` for existing tooling. |
|
||||||
|
| `npm run e2e` | Playwright + axe-core against the SSR bundle. |
|
||||||
|
| `npm run e2e:install` | Install the pinned Chromium build. |
|
||||||
|
| `npm run lighthouse` | Production build, then Lighthouse CI. |
|
||||||
| `npm run ci` | lint, format:check, test:ci, then build. |
|
| `npm run ci` | lint, format:check, test:ci, then build. |
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
@@ -99,6 +102,7 @@ German and English are written idiomatically per language, never machine-transla
|
|||||||
- No `window`, `document`, `navigator`, `localStorage` or `matchMedia` access outside `afterNextRender` or `isPlatformBrowser` guards
|
- No `window`, `document`, `navigator`, `localStorage` or `matchMedia` access outside `afterNextRender` or `isPlatformBrowser` guards
|
||||||
- Use the injected `DOCUMENT` token when the document element is required (it works on server and browser)
|
- Use the injected `DOCUMENT` token when the document element is required (it works on server and browser)
|
||||||
- No user-agent sniffing; use CSS media queries for device capability
|
- No user-agent sniffing; use CSS media queries for device capability
|
||||||
|
- Exception: `isApplePlatform()` in `src/app/core/platform/browser.ts` may read `navigator.userAgentData?.platform ?? navigator.platform` after hydration, only to label the Command key. There is no CSS media query for that key. It is not used for device capability.
|
||||||
- Capability helpers live in `src/app/core/platform/browser.ts` and return conservative defaults on the server
|
- Capability helpers live in `src/app/core/platform/browser.ts` and return conservative defaults on the server
|
||||||
- Helpers that use `inject()` must be called from a field initializer or constructor, never from a lifecycle hook or callback, and the resolved value must be stored on the instance
|
- Helpers that use `inject()` must be called from a field initializer or constructor, never from a lifecycle hook or callback, and the resolved value must be stored on the instance
|
||||||
- Every addressable route must remain prerenderable
|
- Every addressable route must remain prerenderable
|
||||||
@@ -121,8 +125,13 @@ German and English are written idiomatically per language, never machine-transla
|
|||||||
- Real locale-prefixed URLs
|
- Real locale-prefixed URLs
|
||||||
- Server-rendered core text
|
- Server-rendered core text
|
||||||
- Per-route titles from the content layer
|
- Per-route titles from the content layer
|
||||||
|
- Canonical origin is `SITE_CONFIG.siteOrigin` (`https://antoniolede.de`); every absolute URL in the app and tests is derived from it
|
||||||
|
- Per-route metadata (description, canonical, reciprocal hreflang including `x-default`, Open Graph, Twitter, robots) is written by `SeoService` during SSR and on every client navigation
|
||||||
|
- One JSON-LD `@graph` script per route: Person and ProfessionalService on Home, Service on service routes, CreativeWork per public case on projects, WebPage elsewhere
|
||||||
|
- `public/robots.txt`, `public/sitemap.xml` and `public/llms.txt` stay synchronized with `prerenderablePaths()` via `crawl-assets.spec.ts`
|
||||||
|
- The language switch exposes `hreflang` on the alternate-locale link
|
||||||
|
|
||||||
Canonical, hreflang document tags and JSON-LD arrive in the integration phase. The language switch already exposes `hreflang` on the alternate-locale link.
|
Browser binaries, HTML reports, traces and screenshots are not committed (`test-results`, `playwright-report`, `.lighthouseci`).
|
||||||
|
|
||||||
## Test checklist
|
## Test checklist
|
||||||
|
|
||||||
@@ -136,6 +145,8 @@ A change is not done until:
|
|||||||
6. Routing and locale contracts still have unit coverage (paths, locale helpers, navigation links)
|
6. Routing and locale contracts still have unit coverage (paths, locale helpers, navigation links)
|
||||||
7. No new `window` / `document` / `navigator` reads were added outside an SSR-safe guard
|
7. No new `window` / `document` / `navigator` reads were added outside an SSR-safe guard
|
||||||
8. Placeholder or legal copy was not replaced with invented professional claims
|
8. Placeholder or legal copy was not replaced with invented professional claims
|
||||||
|
9. Crawl files still match `SITE_CONFIG.siteOrigin` and `prerenderablePaths()`
|
||||||
|
10. Playwright (`npm run e2e`) and Lighthouse CI (`npm run lighthouse`) still pass when the change affects public HTML, metadata or chrome
|
||||||
|
|
||||||
## Domain boundaries
|
## Domain boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ npm install
|
|||||||
| `npm run format:check` | Check formatting without writing. |
|
| `npm run format:check` | Check formatting without writing. |
|
||||||
| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. |
|
| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. |
|
||||||
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. |
|
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. |
|
||||||
|
| `npm run e2e` | Playwright + axe-core against the production SSR bundle. |
|
||||||
|
| `npm run e2e:install` | Install the pinned Chromium build for Playwright. |
|
||||||
|
| `npm run lighthouse` | Production build, then Lighthouse CI (`lighthouserc.json`). |
|
||||||
| `npm run ci` | lint, format check, tests, then production build. |
|
| `npm run ci` | lint, format check, tests, then production build. |
|
||||||
|
|
||||||
## Local SSR build
|
## Local SSR build
|
||||||
@@ -41,6 +44,8 @@ npm run serve:ssr
|
|||||||
|
|
||||||
The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`.
|
The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`.
|
||||||
|
|
||||||
|
`npm run e2e` builds the SSR bundle and serves it on port 4173. `npm run lighthouse` builds, then starts the SSR server on port 4000. Browser binaries and generated reports (`test-results`, `playwright-report`, `.lighthouseci`) are not committed.
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
48
e2e/a11y.e2e.ts
Normal file
48
e2e/a11y.e2e.ts
Normal 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
33
e2e/contact.e2e.ts
Normal 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
92
e2e/helpers.ts
Normal 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
56
e2e/keyboard.e2e.ts
Normal 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
71
e2e/layout.e2e.ts
Normal 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
74
e2e/seo.e2e.ts
Normal 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
66
e2e/systems-map.e2e.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,17 @@ const prettier = require('eslint-config-prettier');
|
|||||||
|
|
||||||
module.exports = tseslint.config(
|
module.exports = tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ['dist/**', '.angular/**', 'node_modules/**', 'coverage/**'],
|
ignores: [
|
||||||
|
'dist/**',
|
||||||
|
'.angular/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'coverage/**',
|
||||||
|
'e2e/**',
|
||||||
|
'playwright.config.ts',
|
||||||
|
'playwright-report/**',
|
||||||
|
'test-results/**',
|
||||||
|
'.lighthouseci/**',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.ts'],
|
files: ['**/*.ts'],
|
||||||
|
|||||||
31
lighthouserc.json
Normal file
31
lighthouserc.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"startServerCommand": "npm run serve:ssr",
|
||||||
|
"startServerReadyPattern": "Node Express server listening",
|
||||||
|
"url": [
|
||||||
|
"http://127.0.0.1:4000/",
|
||||||
|
"http://127.0.0.1:4000/en",
|
||||||
|
"http://127.0.0.1:4000/leistungen/ai-integration",
|
||||||
|
"http://127.0.0.1:4000/en/projects"
|
||||||
|
],
|
||||||
|
"numberOfRuns": 3,
|
||||||
|
"settings": {
|
||||||
|
"preset": "desktop",
|
||||||
|
"chromeFlags": "--no-sandbox --headless=new"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assert": {
|
||||||
|
"assertions": {
|
||||||
|
"categories:performance": ["error", { "minScore": 0.85 }],
|
||||||
|
"categories:accessibility": ["error", { "minScore": 1 }],
|
||||||
|
"categories:best-practices": ["error", { "minScore": 1 }],
|
||||||
|
"categories:seo": ["error", { "minScore": 1 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"target": "filesystem",
|
||||||
|
"outputDir": ".lighthouseci"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3177
package-lock.json
generated
3177
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,10 @@
|
|||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"serve:ssr": "node dist/Portfolio/server/server.mjs",
|
"serve:ssr": "node dist/Portfolio/server/server.mjs",
|
||||||
"serve:ssr:Portfolio": "node dist/Portfolio/server/server.mjs",
|
"serve:ssr:Portfolio": "node dist/Portfolio/server/server.mjs",
|
||||||
"ci": "npm run lint && npm run format:check && npm run test:ci && npm run build"
|
"ci": "npm run lint && npm run format:check && npm run test:ci && npm run build",
|
||||||
|
"e2e": "playwright test",
|
||||||
|
"e2e:install": "playwright install chromium",
|
||||||
|
"lighthouse": "npm run build && lhci autorun"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
@@ -47,10 +50,14 @@
|
|||||||
"@angular/build": "^21.0.5",
|
"@angular/build": "^21.0.5",
|
||||||
"@angular/cli": "^21.0.5",
|
"@angular/cli": "^21.0.5",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
"@eslint/js": "^9.39.5",
|
"@eslint/js": "^9.39.5",
|
||||||
|
"@lhci/cli": "^0.15.1",
|
||||||
|
"@playwright/test": "1.62.1",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
"angular-eslint": "^21.4.0",
|
"angular-eslint": "^21.4.0",
|
||||||
|
"axe-core": "^4.13.0",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"jsdom": "^27.1.0",
|
"jsdom": "^27.1.0",
|
||||||
|
|||||||
40
playwright.config.ts
Normal file
40
playwright.config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
testMatch: '**/*.e2e.ts',
|
||||||
|
forbidOnly: !!process.env['CI'],
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:4173',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run build && npm run serve:ssr',
|
||||||
|
url: 'http://127.0.0.1:4173',
|
||||||
|
env: { PORT: '4173' },
|
||||||
|
timeout: 300_000,
|
||||||
|
reuseExistingServer: !process.env['CI'],
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'desktop',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mobile',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
hasTouch: true,
|
||||||
|
isMobile: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user