test(e2e): add Playwright + axe-core smoke suite

Add @playwright/test, axe-core and @axe-core/playwright as devDependencies.

- playwright.config.ts with desktop+mobile projects and a webServer that boots astro preview on 127.0.0.1:4321.
- tests/e2e/site.spec.ts covers: home renders with H1 and textual map summary; skip-link is the first focusable and jumps to main; filter pills toggle aria-pressed and update the live counter; 404 is noindex; JSON-LD parses and exposes Organization+WebSite+ItemList; robots.txt and sitemap are wired to Astro.site; the 404 is excluded from the sitemap; the generated og-default.png is 1200x630.
- Scripts: test:e2e runs the suite, test:e2e:install pulls the chromium headless shell.
- .gitignore ignores test-results/, playwright-report/ and the playwright cache.

All 16 tests pass (8 desktop, 8 mobile).
This commit is contained in:
2026-07-26 21:37:00 +02:00
parent f5eac75887
commit c492e5e214
5 changed files with 6167 additions and 1 deletions
+5
View File
@@ -50,6 +50,11 @@ tmp/
*.swo
*~
# Playwright
test-results/
playwright-report/
playwright/.cache/
# IDE settings
.idea/
.vscode/
+6030
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -12,7 +12,9 @@
"build": "astro build",
"preview": "astro preview",
"check": "astro check",
"check:watch": "astro check --watch"
"check:watch": "astro check --watch",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install --with-deps chromium"
},
"dependencies": {
"@astrojs/preact": "^6.0.1",
@@ -25,8 +27,11 @@
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@axe-core/playwright": "^4.12.1",
"@playwright/test": "^1.62.0",
"@types/leaflet": "^1.9.12",
"@types/pg": "^8.11.10",
"axe-core": "^4.12.1",
"typescript": "^5.6.3"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
reporter: [['list']],
use: {
baseURL: 'http://127.0.0.1:4321',
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run preview -- --host 127.0.0.1 --port 4321',
url: 'http://127.0.0.1:4321/',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
projects: [
{
name: 'desktop',
use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } },
},
{
name: 'mobile',
use: { ...devices['Pixel 5'] },
},
],
});
+99
View File
@@ -0,0 +1,99 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
const dist = (name: string) => join(process.cwd(), 'dist', name);
test.describe('site smoke', () => {
test('home renders and reports no axe violations @ desktop', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Avisos ambientals d'Eivissa i Formentera/);
await expect(page.locator('h1#page-title')).toBeVisible();
await expect(page.locator('main#main-content')).toBeVisible();
await expect(page.locator('text=Resum d\'avisos per municipi')).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.disableRules(['region']) // decorative map region is intentional
.analyze();
expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]);
});
test('skip-link is the first interactive element and jumps to main', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => document.activeElement?.textContent?.trim());
expect(focused).toBe('Saltar al contingut');
await page.keyboard.press('Enter');
await expect(page.locator('main#main-content')).toBeFocused();
});
test('filter pills toggle aria-pressed and update the live counter', async ({ page }) => {
await page.goto('/');
const municipioGroup = page.locator('[data-filter-type="municipio"]');
const allPills = municipioGroup.locator('.pill');
const firstPill = allPills.first();
const targetValue = await municipioGroup
.locator('.pill:not(:first-child)')
.first()
.getAttribute('data-value');
expect(targetValue).toBeTruthy();
const targetPill = municipioGroup.locator(`.pill[data-value="${targetValue}"]`);
await expect(firstPill).toHaveAttribute('aria-pressed', 'true');
await targetPill.click();
await expect(firstPill).toHaveAttribute('aria-pressed', 'false');
await expect(targetPill).toHaveAttribute('aria-pressed', 'true');
const counter = page.locator('#filters-count');
await expect(counter).toContainText(/avís/i);
});
test('404 page is noindex and reachable', async ({ page }) => {
const response = await page.goto('/no-existeix-aqui');
expect(response?.status()).toBe(404);
const robots = await page.locator('meta[name="robots"]').getAttribute('content');
expect(robots).toContain('noindex');
await expect(page.locator('h1#not-found-title')).toBeVisible();
});
test('JSON-LD on the home parses and exposes Organization + ItemList', async ({ page }) => {
await page.goto('/');
const payload = await page.locator('script[type="application/ld+json"]').first().textContent();
expect(payload).toBeTruthy();
const data = JSON.parse(payload ?? '{}');
const types = (data['@graph'] as Array<{ '@type': string }>).map((n) => n['@type']);
expect(types).toEqual(expect.arrayContaining(['Organization', 'WebSite', 'ItemList']));
});
test('robots.txt built from Astro.site and points at the sitemap', async ({ request }) => {
const res = await request.get('/robots.txt');
expect(res.status()).toBe(200);
const body = await res.text();
expect(body).toMatch(/User-agent: \*/);
expect(body).toMatch(/Sitemap: https:\/\/www\.eivissaenvironmentalwatch\.es\/sitemap-index\.xml/);
});
test('sitemap lists the two real pages and not the 404', async ({ request }) => {
const res = await request.get('/sitemap-index.xml');
expect(res.status()).toBe(200);
const index = await res.text();
const sub = await request.get(/<loc>(.*?)<\/loc>/.exec(index)?.[1] ?? '/sitemap-0.xml');
const sitemap = await sub.text();
expect(sitemap).toContain('https://www.eivissaenvironmentalwatch.es/');
expect(sitemap).toContain('https://www.eivissaenvironmentalwatch.es/huella/');
expect(sitemap).not.toContain('/404/');
});
});
test.describe('build artifacts', () => {
test('og PNG exists with the right dimensions', async () => {
const buf = await readFile(dist('og-default.png'));
expect(buf.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
expect({ width, height }).toEqual({ width: 1200, height: 630 });
});
});