141 lines
7.1 KiB
JavaScript
141 lines
7.1 KiB
JavaScript
// @ts-check
|
||
// Tests: A1–A5 (feature checks) and AX1–AX5 (axe scans)
|
||
const { test, expect } = require('@playwright/test');
|
||
|
||
// ── A1: Skip link ──────────────────────────────────────────────────────────────
|
||
test('A1: skip link targets #main-content and is first focusable element', async ({ page }) => {
|
||
await page.goto('/');
|
||
const skipLink = page.locator('.skip-link');
|
||
await expect(skipLink).toBeAttached();
|
||
await expect(skipLink).toHaveAttribute('href', '#main-content');
|
||
await expect(page.locator('#main-content')).toBeAttached();
|
||
});
|
||
|
||
// ── A2: Color token contrast ───────────────────────────────────────────────────
|
||
test('A2: contrast tokens meet WCAG AA 4.5:1 floor', async ({ page }) => {
|
||
await page.goto('/');
|
||
const [muted, accent] = await page.evaluate(() => [
|
||
getComputedStyle(document.documentElement).getPropertyValue('--color-ink-muted').trim(),
|
||
getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(),
|
||
]);
|
||
expect(muted.toLowerCase()).toBe('#90887e');
|
||
expect(accent.toLowerCase()).toBe('#2e9880');
|
||
});
|
||
|
||
// ── A3: Filter button aria-pressed + toggle aria-expanded ──────────────────────
|
||
const TRIP_URL = '/trips/japan-korea-2026';
|
||
|
||
test('A3a: All-content filter has aria-pressed="true" on load', async ({ page }) => {
|
||
await page.goto(TRIP_URL);
|
||
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'false');
|
||
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toHaveAttribute('aria-pressed', 'false');
|
||
});
|
||
|
||
test('A3b: clicking Journal filter toggles aria-pressed', async ({ page }) => {
|
||
await page.goto(TRIP_URL);
|
||
await page.click('.trip-filter-btn[data-filter="journal"]');
|
||
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'false');
|
||
});
|
||
|
||
test('A3c: Stats toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
|
||
await page.goto(TRIP_URL);
|
||
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-controls', 'trip-stats-block');
|
||
});
|
||
|
||
test('A3d: clicking Stats toggle sets aria-expanded="true" then back to false', async ({ page }) => {
|
||
await page.goto(TRIP_URL);
|
||
await page.click('#trip-stats-toggle');
|
||
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'true');
|
||
await page.click('#trip-stats-toggle');
|
||
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||
});
|
||
|
||
const ITALY_URL = '/trips/italy-2026-demo';
|
||
|
||
test('A3e: Cycling toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
|
||
await page.goto(ITALY_URL);
|
||
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-controls', 'trip-cycling-block');
|
||
});
|
||
|
||
test('A3f: clicking Cycling toggle sets aria-expanded="true" then back to false', async ({ page }) => {
|
||
await page.goto(ITALY_URL);
|
||
await page.click('#trip-cycling-toggle');
|
||
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'true');
|
||
await page.click('#trip-cycling-toggle');
|
||
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||
});
|
||
|
||
// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
|
||
test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
|
||
await page.goto('/trips/japan-korea-2026/dailies');
|
||
const strips = page.locator('.journal-photo-strip');
|
||
const count = await strips.count();
|
||
if (count === 0) return;
|
||
for (let i = 0; i < count; i++) {
|
||
await expect(strips.nth(i)).toHaveAttribute('role', 'region');
|
||
await expect(strips.nth(i)).toHaveAttribute('aria-label', 'Photo strip');
|
||
}
|
||
});
|
||
|
||
test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
|
||
await page.goto('/trips/japan-korea-2026/dailies');
|
||
const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
|
||
els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
|
||
);
|
||
if (multiCount === 0) return;
|
||
await expect(page.locator('.strip-prev').first()).toBeAttached();
|
||
await expect(page.locator('.strip-next').first()).toBeAttached();
|
||
await expect(page.locator('.strip-prev').first()).toHaveAttribute('aria-label', 'Previous photo');
|
||
await expect(page.locator('.strip-next').first()).toHaveAttribute('aria-label', 'Next photo');
|
||
});
|
||
|
||
// ── A5: GPX delete button unique accessible names ──────────────────────────────
|
||
test('A5: GPX delete buttons have unique aria-labels per filename', async ({ page }) => {
|
||
await page.route('**/api/v1/pages**/media', async route => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
data: [
|
||
{ filename: 'tokyo-day1.gpx', size: 102400, modified: '2026-03-25T10:00:00Z' }
|
||
]
|
||
})
|
||
});
|
||
});
|
||
await page.goto('/gpx-manager');
|
||
const deleteBtn = page.locator('.gpx-trip').first().locator('.gpx-delete[data-filename="tokyo-day1.gpx"]');
|
||
await expect(deleteBtn).toBeVisible();
|
||
await expect(deleteBtn).toHaveAttribute('aria-label', 'Delete tokyo-day1.gpx');
|
||
});
|
||
|
||
// ── AX1–AX5: axe-core WCAG 2.1 AA regression scans ───────────────────────────
|
||
const { AxeBuilder } = require('@axe-core/playwright');
|
||
|
||
const WCAG_TAGS = ['wcag2a', 'wcag2aa'];
|
||
const BLOCKING = ['critical', 'serious'];
|
||
|
||
function axeScan(id, url) {
|
||
test(`${id}: ${url} passes axe WCAG 2.1 AA (critical/serious)`, async ({ page }) => {
|
||
await page.goto(url);
|
||
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
|
||
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
|
||
expect(
|
||
violations,
|
||
violations.map(v =>
|
||
`[${v.impact}] ${v.id}: ${v.description}\n ` +
|
||
v.nodes.map(n => n.html).join('\n ')
|
||
).join('\n\n')
|
||
).toHaveLength(0);
|
||
});
|
||
}
|
||
|
||
axeScan('AX1', '/');
|
||
axeScan('AX2', '/trips/japan-korea-2026');
|
||
axeScan('AX3', '/trips/japan-korea-2026/dailies');
|
||
axeScan('AX4', '/trips/japan-korea-2026/dailies/2026-06-17.entry');
|
||
axeScan('AX5', '/trips');
|