Skip to content

Commit a0b9796

Browse files
PIE-534 add a11y scenario suite
Add a dedicated Axe accessibility scenario suite with local reporting so PIE element accessibility coverage can be inventoried and triaged without blocking builds. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8774613 commit a0b9796

33 files changed

Lines changed: 3587 additions & 34 deletions

.github/workflows/a11y.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: A11y Scenarios
2+
3+
on:
4+
workflow_dispatch:
5+
6+
concurrency:
7+
group: ${{ github.workflow }}-${{ github.ref }}
8+
cancel-in-progress: true
9+
10+
jobs:
11+
axe-scenarios:
12+
name: Axe scenarios and inventory (non-blocking)
13+
runs-on: ubuntu-latest
14+
continue-on-error: true
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: 20.x
20+
- uses: oven-sh/setup-bun@v2
21+
with:
22+
bun-version: 1.3.11
23+
- run: bun install --frozen-lockfile
24+
- run: bun x svelte-kit sync
25+
working-directory: apps/element-demo
26+
- run: bun run --cwd apps/element-demo generate-imports
27+
- run: bun run build
28+
- run: bunx playwright install --with-deps
29+
- run: bun run test:a11y
30+
- run: bun run test:a11y:inventory
31+
- uses: actions/upload-artifact@v4
32+
if: always()
33+
with:
34+
name: axe-a11y-reports
35+
path: |
36+
apps/element-demo/test-results/a11y/
37+
apps/element-demo/playwright-a11y-report/
38+
if-no-files-found: warn
39+
retention-days: 30

apps/element-demo/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
"generate-imports": "bun run scripts/generate-element-imports.ts",
1313
"predev": "bun run generate-imports && bun run --cwd ../.. verify:no-local-paths apps/element-demo/src/lib/element-imports.js apps/element-demo/src/lib/element-imports.d.ts",
1414
"test:e2e": "playwright test",
15+
"test:a11y": "playwright test --config=playwright.a11y.config.ts",
16+
"test:a11y:inventory": "A11Y_SUITE=inventory playwright test --config=playwright.a11y.config.ts",
17+
"test:a11y:ui": "playwright test --config=playwright.a11y.config.ts --ui",
18+
"test:a11y:headed": "playwright test --config=playwright.a11y.config.ts --headed",
1519
"test:e2e:iife": "RUN_IIFE_E2E=1 playwright test iife-usefulness.spec.ts",
1620
"test:e2e:iife:suite": "RUN_IIFE_E2E=1 MATRIX_STRATEGIES=esm,iife playwright test iife-usefulness.spec.ts smoke-matrix.spec.ts",
1721
"test:e2e:iife:suite:orchestrated": "PIE_IIFE_EXTERNAL_SERVER=1 RUN_IIFE_E2E=1 MATRIX_STRATEGIES=esm,iife playwright test iife-usefulness.spec.ts smoke-matrix.spec.ts",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
import { existsSync, readdirSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { homedir } from 'node:os';
5+
6+
function resolveLocalBrowsersDir(): string | undefined {
7+
const systemCache = join(homedir(), 'Library', 'Caches', 'ms-playwright');
8+
if (existsSync(systemCache)) {
9+
return systemCache;
10+
}
11+
12+
const bunModulesDir = join(process.cwd(), 'node_modules', '.bun');
13+
if (!existsSync(bunModulesDir)) {
14+
return undefined;
15+
}
16+
17+
const entries = readdirSync(bunModulesDir).filter((entry) =>
18+
entry.startsWith('playwright-core@')
19+
);
20+
21+
for (const entry of entries) {
22+
const candidate = join(
23+
bunModulesDir,
24+
entry,
25+
'node_modules',
26+
'playwright-core',
27+
'.local-browsers'
28+
);
29+
if (existsSync(candidate)) {
30+
return candidate;
31+
}
32+
}
33+
34+
return undefined;
35+
}
36+
37+
function resolveLocalChromium(): string | undefined {
38+
const localBrowsersDir = resolveLocalBrowsersDir();
39+
if (!localBrowsersDir || !existsSync(localBrowsersDir)) {
40+
return undefined;
41+
}
42+
43+
const entries = readdirSync(localBrowsersDir).filter((entry) =>
44+
entry.startsWith('chromium_headless_shell-')
45+
);
46+
47+
for (const entry of entries) {
48+
const arm64Path = join(
49+
localBrowsersDir,
50+
entry,
51+
'chrome-headless-shell-mac-arm64',
52+
'chrome-headless-shell'
53+
);
54+
if (existsSync(arm64Path)) {
55+
return arm64Path;
56+
}
57+
58+
const x64Path = join(
59+
localBrowsersDir,
60+
entry,
61+
'chrome-headless-shell-mac-x64',
62+
'chrome-headless-shell'
63+
);
64+
if (existsSync(x64Path)) {
65+
return x64Path;
66+
}
67+
}
68+
69+
return undefined;
70+
}
71+
72+
const localChromium = resolveLocalChromium();
73+
const useExternalServer = process.env.PIE_A11Y_EXTERNAL_SERVER === '1';
74+
const suiteName = process.env.A11Y_SUITE === 'inventory' ? 'inventory' : 'scenarios';
75+
const htmlReportDir =
76+
process.env.A11Y_PLAYWRIGHT_REPORT_DIR || `playwright-a11y-report/${suiteName}`;
77+
78+
export default defineConfig({
79+
testDir: './test/a11y',
80+
testMatch: ['**/*.spec.ts'],
81+
fullyParallel: false,
82+
forbidOnly: !!process.env.CI,
83+
retries: 0,
84+
workers: 1,
85+
reporter: [['html', { outputFolder: htmlReportDir }], ['list']],
86+
timeout: 45_000,
87+
expect: {
88+
timeout: 10_000,
89+
},
90+
use: {
91+
baseURL: 'http://localhost:5222',
92+
trace: 'retain-on-failure',
93+
screenshot: 'only-on-failure',
94+
actionTimeout: 10_000,
95+
navigationTimeout: 20_000,
96+
...(localChromium ? { launchOptions: { executablePath: localChromium } } : {}),
97+
},
98+
projects: [
99+
{
100+
name: 'chromium',
101+
use: { ...devices['Desktop Chrome'] },
102+
},
103+
],
104+
webServer: useExternalServer
105+
? undefined
106+
: {
107+
command: 'bun run dev',
108+
url: 'http://localhost:5222',
109+
reuseExistingServer: true,
110+
timeout: 120_000,
111+
},
112+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<script lang="ts">
2+
import { onDestroy } from 'svelte';
3+
import type { A11yRunJobSnapshot, A11yRunRequest, A11yRunScope } from './run-types';
4+
5+
let {
6+
scope,
7+
element,
8+
scenario,
9+
label,
10+
help,
11+
}: {
12+
scope: A11yRunScope;
13+
element?: string;
14+
scenario?: string;
15+
label: string;
16+
help?: string;
17+
} = $props();
18+
19+
let job = $state<A11yRunJobSnapshot | null>(null);
20+
let error = $state<string | null>(null);
21+
let pollingTimeout: ReturnType<typeof setTimeout> | null = null;
22+
23+
const isRunning = $derived(job?.status === 'queued' || job?.status === 'running');
24+
const isComplete = $derived(!!job && !isRunning);
25+
const formattedReportLink = $derived(
26+
job?.reportLinks.find((link) => link.label === 'Formatted report')
27+
);
28+
29+
function statusClass(status: A11yRunJobSnapshot['status']) {
30+
if (status === 'passed') {
31+
return 'badge-success';
32+
}
33+
if (status === 'findings') {
34+
return 'badge-warning';
35+
}
36+
if (status === 'failed') {
37+
return 'badge-error';
38+
}
39+
return 'badge-info';
40+
}
41+
42+
async function startRun() {
43+
error = null;
44+
const payload: A11yRunRequest = {
45+
scope,
46+
element,
47+
scenario,
48+
};
49+
50+
try {
51+
const response = await fetch('/a11y/api/runs', {
52+
method: 'POST',
53+
headers: {
54+
'content-type': 'application/json',
55+
},
56+
body: JSON.stringify(payload),
57+
});
58+
59+
const body = await response.json();
60+
if (!response.ok) {
61+
throw new Error(body.error ?? 'Unable to start a11y run');
62+
}
63+
64+
job = body as A11yRunJobSnapshot;
65+
schedulePoll(job.id);
66+
} catch (err) {
67+
error = err instanceof Error ? err.message : String(err);
68+
}
69+
}
70+
71+
function schedulePoll(runId: string) {
72+
clearPoll();
73+
pollingTimeout = setTimeout(() => {
74+
void refreshRun(runId);
75+
}, 1000);
76+
}
77+
78+
async function refreshRun(runId: string) {
79+
try {
80+
const response = await fetch(`/a11y/api/runs/${runId}`);
81+
const body = await response.json();
82+
if (!response.ok) {
83+
throw new Error(body.error ?? 'Unable to refresh a11y run');
84+
}
85+
86+
job = body as A11yRunJobSnapshot;
87+
if (job.status === 'queued' || job.status === 'running') {
88+
schedulePoll(job.id);
89+
}
90+
} catch (err) {
91+
error = err instanceof Error ? err.message : String(err);
92+
clearPoll();
93+
}
94+
}
95+
96+
function clearPoll() {
97+
if (pollingTimeout) {
98+
clearTimeout(pollingTimeout);
99+
pollingTimeout = null;
100+
}
101+
}
102+
103+
onDestroy(clearPoll);
104+
</script>
105+
106+
<div class="rounded-lg border border-base-300 bg-base-100 p-4 shadow-sm">
107+
<div class="flex flex-wrap items-start justify-between gap-3">
108+
<div>
109+
<button class="btn btn-primary btn-sm" type="button" onclick={startRun} disabled={isRunning}>
110+
{isRunning ? 'Running...' : label}
111+
</button>
112+
{#if help}
113+
<p class="text-xs text-base-content/60 mt-2">{help}</p>
114+
{/if}
115+
</div>
116+
117+
{#if job}
118+
<span class="badge {statusClass(job.status)}">{job.status}</span>
119+
{/if}
120+
</div>
121+
122+
{#if error}
123+
<div class="alert alert-error mt-3 py-2 text-sm">{error}</div>
124+
{/if}
125+
126+
{#if isComplete && formattedReportLink}
127+
<div class="mt-3">
128+
<a
129+
class="btn btn-sm btn-outline btn-primary"
130+
href={formattedReportLink.href}
131+
target="_blank"
132+
rel="noreferrer"
133+
>
134+
Open report
135+
</a>
136+
</div>
137+
{/if}
138+
139+
{#if job?.status === 'failed' && job.outputTail}
140+
<pre class="mt-3 max-h-48 overflow-auto rounded bg-base-200 p-3 text-xs whitespace-pre-wrap">{job.outputTail}</pre>
141+
{/if}
142+
</div>

0 commit comments

Comments
 (0)