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

6
.gitignore vendored
View File

@@ -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

View File

@@ -8,3 +8,7 @@ public/**/*.svg
Dockerfile Dockerfile
docker-compose.yml docker-compose.yml
.cursor .cursor
test-results
playwright-report
.lighthouseci
blob-report

View File

@@ -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

View File

@@ -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
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);
}
});
});

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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,
},
},
],
});