Files
intotheeast-com/tests/ui/accessibility.spec.js
T

125 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-check
// Tests: A1A5 (feature checks) and AX1AX5 (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');
});
// ── 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');
});
// ── AX1AX5: 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');