mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52:17 -05:00
client_v2: add mobile accessibility audit (Playwright + axe-core)
Adds an npm run audit:a11y target that runs axe-core (WCAG 2.0/2.1 A+AA plus the 2.2 target-size rule) and a custom tap-target measurement across the main surfaces under Pixel 5 emulation, reusing the dev server on 5174. Results are aggregated to a11y-report.md via globalTeardown so they survive Playwright worker restarts.
This commit is contained in:
1 parent
b7772ca743
commit
8a5cf7ec73
10 files changed
+465
-2
No files matched your search
@@ -7,3 +7,8 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Playwright a11y audit output
|
||||
/playwright-report
|
||||
/test-results
|
||||
/a11y-report.md
|
||||
@@ -158,3 +158,17 @@ npm run dev # http://localhost:5174
|
||||
npm run check # svelte-check (type gate)
|
||||
npm run build # static SPA build (adapter-static, index.html fallback)
|
||||
```
|
||||
|
||||
### Mobile accessibility audit
|
||||
|
||||
```bash
|
||||
npm run audit:a11y # Playwright + axe-core under Pixel 5 emulation
|
||||
npm run audit:a11y:report # open the last HTML report
|
||||
```
|
||||
|
||||
Runs [axe-core](https://github.com/dequelabs/axe-core) (WCAG 2.0/2.1 A+AA plus the
|
||||
2.2 `target-size` rule) and a custom tap-target measurement across the main
|
||||
surfaces (library, locations, labels, settings, add-spool modal, inspector
|
||||
drawer). Reuses a `vite dev` already on 5174, or starts one. Human-readable
|
||||
results land in `a11y-report.md`; the tap-target advisory minimum is 44px (Apple
|
||||
HIG / Material), stricter than the WCAG 24px floor. See `e2e/a11y-mobile.spec.ts`.
|
||||
@@ -0,0 +1,190 @@
|
||||
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { SURFACES_DIR, TAP_MIN, type SurfaceResult, type TapTarget } from './report';
|
||||
|
||||
// Mobile-accessibility audit for client_v2.
|
||||
//
|
||||
// Two independent checks run against every surface, under a Pixel 5 mobile
|
||||
// emulation (see playwright.config.ts):
|
||||
//
|
||||
// 1. axe-core — the full WCAG 2.0/2.1 A + AA ruleset PLUS the WCAG 2.2
|
||||
// `target-size` rule (SC 2.5.8, 24x24 CSS px minimum). Real conformance
|
||||
// failures.
|
||||
//
|
||||
// 2. A custom tap-target measurement — flags interactive controls smaller
|
||||
// than TAP_MIN px in either dimension. An *advisory* thumb-comfort check
|
||||
// (Apple HIG 44px / Material 48px), stricter than the WCAG minimum and
|
||||
// catching targets axe considers technically compliant.
|
||||
//
|
||||
// Each surface's findings are written to SURFACES_DIR as JSON; the consolidated
|
||||
// `a11y-report.md` is rendered by globalTeardown (which survives worker
|
||||
// restarts). Raw findings are also attached to the Playwright HTML report.
|
||||
|
||||
let surfaceCount = 0;
|
||||
|
||||
// Let the DOM and any async lists settle. The app holds an open live-sync
|
||||
// WebSocket, so `networkidle` never fires — we bound it and fall through.
|
||||
async function settle(page: Page, ms = 500) {
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
await page.waitForLoadState('networkidle', { timeout: 2500 }).catch(() => {});
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
// Measure every interactive control in the live DOM and return those smaller
|
||||
// than `min` px in either dimension. Runs in the browser.
|
||||
function collectSmallTargets(min: number): TapTarget[] {
|
||||
const SELECTOR = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input:not([type="hidden"])',
|
||||
'select',
|
||||
'textarea',
|
||||
'summary',
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="radio"]',
|
||||
'[role="switch"]',
|
||||
'[role="tab"]',
|
||||
'[role="menuitem"]',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
].join(',');
|
||||
|
||||
const out: TapTarget[] = [];
|
||||
const seen = new Set<Element>();
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll(SELECTOR))) {
|
||||
if (seen.has(el)) continue;
|
||||
seen.add(el);
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
|
||||
if ((el as HTMLButtonElement).disabled) continue;
|
||||
|
||||
// WCAG exempts targets rendered inline within a run of text (e.g. a link
|
||||
// inside a sentence). Skip inline <a> elements to avoid false positives.
|
||||
if (el.tagName === 'A' && style.display.startsWith('inline')) continue;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue; // not rendered
|
||||
if (rect.width >= min && rect.height >= min) continue;
|
||||
|
||||
const id = el.id ? `#${el.id}` : '';
|
||||
const rawCls = (el as HTMLElement).className;
|
||||
const cls =
|
||||
typeof rawCls === 'string' && rawCls.trim()
|
||||
? '.' + rawCls.trim().split(/\s+/).slice(0, 2).join('.')
|
||||
: '';
|
||||
const aria = el.getAttribute('aria-label');
|
||||
|
||||
out.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: (aria || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40),
|
||||
selector: `${el.tagName.toLowerCase()}${id}${cls}`,
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Run both checks against whatever is currently rendered and record the result.
|
||||
async function audit(page: Page, testInfo: TestInfo, label: string) {
|
||||
await settle(page);
|
||||
|
||||
const axeResults = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
|
||||
.analyze();
|
||||
|
||||
const smallTargets = await page.evaluate(collectSmallTargets, TAP_MIN);
|
||||
|
||||
const surface: SurfaceResult = {
|
||||
order: surfaceCount++,
|
||||
label,
|
||||
url: page.url(),
|
||||
axe: axeResults.violations.map((v) => ({
|
||||
id: v.id,
|
||||
impact: v.impact,
|
||||
help: v.help,
|
||||
nodes: v.nodes.length,
|
||||
targets: v.nodes.slice(0, 5).map((n) => n.target.join(' '))
|
||||
})),
|
||||
smallTargets
|
||||
};
|
||||
|
||||
const file = path.join(SURFACES_DIR, `${label.replace(/[^a-z0-9]+/gi, '-')}.json`);
|
||||
await writeFile(file, JSON.stringify(surface, null, 2), 'utf8');
|
||||
|
||||
await testInfo.attach(`${label} — axe violations`, {
|
||||
body: JSON.stringify(axeResults.violations, null, 2),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
await testInfo.attach(`${label} — small tap targets`, {
|
||||
body: JSON.stringify(smallTargets, null, 2),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
|
||||
// Soft assertions: the run completes and reports every surface, but the test
|
||||
// is still marked failed if anything is out of spec — useful as a CI gate.
|
||||
const serious = surface.axe.filter((v) => v.impact === 'serious' || v.impact === 'critical');
|
||||
expect.soft(serious, `${label}: serious/critical axe violations`).toEqual([]);
|
||||
expect.soft(smallTargets, `${label}: tap targets under ${TAP_MIN}px`).toEqual([]);
|
||||
}
|
||||
|
||||
const STATIC_ROUTES: { path: string; label: string }[] = [
|
||||
{ path: '/', label: 'library (home)' },
|
||||
{ path: '/locations', label: 'locations' },
|
||||
{ path: '/labels', label: 'labels' },
|
||||
{ path: '/settings', label: 'settings' }
|
||||
];
|
||||
|
||||
for (const route of STATIC_ROUTES) {
|
||||
test(`a11y: ${route.label}`, async ({ page }, testInfo) => {
|
||||
await page.goto(route.path);
|
||||
await audit(page, testInfo, route.label);
|
||||
});
|
||||
}
|
||||
|
||||
test('a11y: add-spool modal', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
await settle(page);
|
||||
// The mobile TopBar exposes the add control via aria-label "Add spools".
|
||||
const addBtn = page.getByRole('button', { name: /add spool/i }).first();
|
||||
if ((await addBtn.count()) === 0) test.skip(true, 'no add button found');
|
||||
await addBtn.click();
|
||||
await audit(page, testInfo, 'add-spool modal');
|
||||
});
|
||||
|
||||
test('a11y: inspector drawer (mobile bottom sheet)', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
await settle(page, 800);
|
||||
// Select the first library row to open the mobile drawer.
|
||||
const firstItem = page.locator('.list-pane a[href], .list-pane [role="button"]').first();
|
||||
if ((await firstItem.count()) === 0) test.skip(true, 'no list items to select');
|
||||
await firstItem.click({ timeout: 5000 }).catch(() => {});
|
||||
await audit(page, testInfo, 'inspector drawer');
|
||||
});
|
||||
|
||||
test('a11y: detail pages (filament / spool)', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
await settle(page, 800);
|
||||
|
||||
// Discover a real detail URL from the live DOM rather than hardcoding an id.
|
||||
const hrefs = await page.$$eval('a[href]', (els) =>
|
||||
els.map((e) => (e as HTMLAnchorElement).getAttribute('href') || '')
|
||||
);
|
||||
const filament = hrefs.find((h) => /\/filament\/show\/\d/.test(h));
|
||||
const spool = hrefs.find((h) => /\/spool\/show\/\d/.test(h));
|
||||
|
||||
if (filament) {
|
||||
await page.goto(filament);
|
||||
await audit(page, testInfo, 'filament detail');
|
||||
}
|
||||
if (spool) {
|
||||
await page.goto(spool);
|
||||
await audit(page, testInfo, 'spool detail');
|
||||
}
|
||||
if (!filament && !spool) test.skip(true, 'no detail links found in library');
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { rm, mkdir } from 'node:fs/promises';
|
||||
import { SURFACES_DIR, REPORT_PATH } from './report';
|
||||
|
||||
// Runs once before the suite: clear any per-surface results and the report from
|
||||
// a previous run so the new report reflects only this run.
|
||||
export default async function globalSetup() {
|
||||
await rm(SURFACES_DIR, { recursive: true, force: true });
|
||||
await rm(REPORT_PATH, { force: true });
|
||||
await mkdir(SURFACES_DIR, { recursive: true });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { SURFACES_DIR, REPORT_PATH, TAP_MIN, type SurfaceResult } from './report';
|
||||
|
||||
// Runs once after the suite (in the main process, so it survives worker
|
||||
// restarts): read every per-surface JSON and render the consolidated report.
|
||||
export default async function globalTeardown() {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = (await readdir(SURFACES_DIR)).filter((f) => f.endsWith('.json'));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const results: SurfaceResult[] = [];
|
||||
for (const f of files) {
|
||||
try {
|
||||
results.push(JSON.parse(await readFile(path.join(SURFACES_DIR, f), 'utf8')));
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => a.order - b.order);
|
||||
|
||||
const esc = (s: string) => s.replace(/\|/g, '\\|');
|
||||
const lines: string[] = [];
|
||||
lines.push('# Mobile accessibility audit — client_v2');
|
||||
lines.push('');
|
||||
lines.push(`Device profile: **Pixel 5** (393×851 CSS px, touch). Generated ${new Date().toISOString()}.`);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`Advisory tap-target minimum: **${TAP_MIN}px**. WCAG 2.2 target-size (24px) failures appear under axe.`
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
const totalAxe = results.reduce((n, s) => n + s.axe.reduce((m, v) => m + v.nodes, 0), 0);
|
||||
const totalSmall = results.reduce((n, s) => n + s.smallTargets.length, 0);
|
||||
lines.push('## Summary');
|
||||
lines.push('');
|
||||
lines.push(`- Surfaces audited: **${results.length}**`);
|
||||
lines.push(`- Total axe violation instances: **${totalAxe}**`);
|
||||
lines.push(`- Total tap targets under ${TAP_MIN}px: **${totalSmall}**`);
|
||||
lines.push('');
|
||||
|
||||
for (const s of results) {
|
||||
lines.push(`## ${s.label}`);
|
||||
lines.push(`\`${s.url}\``);
|
||||
lines.push('');
|
||||
if (s.axe.length === 0) {
|
||||
lines.push('**axe:** no violations ✅');
|
||||
} else {
|
||||
lines.push('**axe violations:**');
|
||||
lines.push('');
|
||||
lines.push('| Rule | Impact | Instances | Example target |');
|
||||
lines.push('| --- | --- | --- | --- |');
|
||||
for (const v of s.axe) {
|
||||
lines.push(
|
||||
`| ${v.id} — ${esc(v.help)} | ${v.impact ?? '?'} | ${v.nodes} | \`${esc((v.targets[0] ?? '').slice(0, 50))}\` |`
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
if (s.smallTargets.length === 0) {
|
||||
lines.push(`**Tap targets:** all ≥ ${TAP_MIN}px ✅`);
|
||||
} else {
|
||||
lines.push(`**Tap targets under ${TAP_MIN}px (${s.smallTargets.length}):**`);
|
||||
lines.push('');
|
||||
lines.push('| Element | Size (w×h) | Text/label |');
|
||||
lines.push('| --- | --- | --- |');
|
||||
for (const t of s.smallTargets.slice(0, 30)) {
|
||||
lines.push(`| \`${esc(t.selector.slice(0, 40))}\` | ${t.width}×${t.height} | ${esc(t.text)} |`);
|
||||
}
|
||||
if (s.smallTargets.length > 30) lines.push(`| … | | +${s.smallTargets.length - 30} more |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
await writeFile(REPORT_PATH, lines.join('\n'), 'utf8');
|
||||
console.log(`\nWrote ${path.relative(process.cwd(), REPORT_PATH)} (${results.length} surfaces)`);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Shared types and paths for the mobile-a11y audit's file-based result
|
||||
// aggregation. Each test writes one JSON file per audited surface into
|
||||
// SURFACES_DIR; globalTeardown reads them all and renders a11y-report.md.
|
||||
//
|
||||
// We go through disk rather than module-level state because Playwright may
|
||||
// recycle the worker process between failing tests, which would wipe any
|
||||
// in-memory accumulator before the final report is written.
|
||||
import path from 'node:path';
|
||||
|
||||
export const TAP_MIN = 44;
|
||||
|
||||
export const SURFACES_DIR = path.resolve('test-results', 'a11y-surfaces');
|
||||
export const REPORT_PATH = path.resolve('a11y-report.md');
|
||||
|
||||
export interface TapTarget {
|
||||
tag: string;
|
||||
text: string;
|
||||
selector: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface SurfaceResult {
|
||||
order: number;
|
||||
label: string;
|
||||
url: string;
|
||||
axe: {
|
||||
id: string;
|
||||
impact: string | null | undefined;
|
||||
help: string;
|
||||
nodes: number;
|
||||
targets: string[];
|
||||
}[];
|
||||
smallTargets: TapTarget[];
|
||||
}
|
||||
@@ -15,7 +15,9 @@ export default ts.config(
|
||||
'package/',
|
||||
'src/lib/paraglide/', // auto-generated by inlang paraglide-js
|
||||
'project.inlang/', // inlang-managed
|
||||
'locales/' // Weblate-managed translation files
|
||||
'locales/', // Weblate-managed translation files
|
||||
'playwright-report/', // generated a11y-audit report (bundled/minified JS)
|
||||
'test-results/' // generated Playwright artifacts
|
||||
]
|
||||
},
|
||||
js.configs.recommended,
|
||||
|
||||
Generated
+88
@@ -17,8 +17,10 @@
|
||||
"svelte-konva": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@inlang/paraglide-js": "^2.18.2",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
@@ -36,6 +38,19 @@
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@axe-core/playwright": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz",
|
||||
"integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"axe-core": "~4.12.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright-core": ">= 1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -784,6 +799,22 @@
|
||||
"svelte": "^5"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
||||
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -1696,6 +1727,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
|
||||
"integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -2925,6 +2966,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
|
||||
@@ -14,11 +14,15 @@
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"lint:eslint": "eslint ."
|
||||
"lint:eslint": "eslint .",
|
||||
"audit:a11y": "playwright test",
|
||||
"audit:a11y:report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@inlang/paraglide-js": "^2.18.2",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Playwright config dedicated to the mobile-accessibility audit. It runs a
|
||||
// single mobile-emulated project (Pixel 5: 393x851 CSS px, touch enabled) so
|
||||
// tap-target geometry and mobile layout are measured under realistic phone
|
||||
// conditions.
|
||||
//
|
||||
// The webServer block reuses an already-running `vite dev` on 5174 (the usual
|
||||
// dev loop) and only spins one up if nothing is listening there.
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
globalTeardown: './e2e/global-teardown.ts',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
|
||||
use: {
|
||||
baseURL: process.env.AUDIT_BASE_URL ?? 'http://localhost:5174',
|
||||
trace: 'retain-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] }
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5174',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user