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
.DS_Store
Thumbs.db
# Browser and audit artifacts
/test-results
/playwright-report
/blob-report
/.lighthouseci

View File

@@ -8,3 +8,7 @@ public/**/*.svg
Dockerfile
docker-compose.yml
.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 serve:ssr` | Serve the production SSR bundle. |
| `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. |
## 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
- 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
- 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
- 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
@@ -121,8 +125,13 @@ German and English are written idiomatically per language, never machine-transla
- Real locale-prefixed URLs
- Server-rendered core text
- 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
@@ -136,6 +145,8 @@ A change is not done until:
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
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

View File

@@ -30,6 +30,9 @@ npm install
| `npm run format:check` | Check formatting without writing. |
| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. |
| `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. |
## 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`.
`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
```

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(
{
ignores: ['dist/**', '.angular/**', 'node_modules/**', 'coverage/**'],
ignores: [
'dist/**',
'.angular/**',
'node_modules/**',
'coverage/**',
'e2e/**',
'playwright.config.ts',
'playwright-report/**',
'test-results/**',
'.lighthouseci/**',
],
},
{
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 .",
"serve:ssr": "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": {
"printWidth": 100,
@@ -47,10 +50,14 @@
"@angular/build": "^21.0.5",
"@angular/cli": "^21.0.5",
"@angular/compiler-cli": "^21.0.0",
"@axe-core/playwright": "^4.13.0",
"@eslint/js": "^9.39.5",
"@lhci/cli": "^0.15.1",
"@playwright/test": "1.62.1",
"@types/express": "^5.0.1",
"@types/node": "^20.17.19",
"angular-eslint": "^21.4.0",
"axe-core": "^4.13.0",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"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,
},
},
],
});