feat: add accessibility scanner workflow#1636
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a GitHub Actions workflow intended to run an automated accessibility scan against a locally started (Docker-based) deployment of the app, and optionally file grouped issues based on findings.
Changes:
- Introduces
.github/workflows/a11y-scan.yamlto start Postgres + backend + frontend + gateway via Docker on the runner. - Adds readiness checks for DB and app health before running
github/accessibility-scanner@v2. - Captures backend/gateway logs after the scan attempt.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
5ebe28f to
42d5a1e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - name: Start database | ||
| run: | | ||
| docker run -d \ | ||
| --network a11y-network \ | ||
| --name postgres \ | ||
| -e POSTGRES_USER=admin \ | ||
| -e POSTGRES_PASSWORD=secret \ | ||
| -e POSTGRES_DB=cccc \ | ||
| pgvector/pgvector:pg16 |
There was a problem hiding this comment.
The Postgres container password is hardcoded (POSTGRES_PASSWORD=secret) and the same value is embedded later in DB_URL. Please wire the DB password through a secret or a generated runtime value (and reference it consistently) so credentials aren’t committed to the workflow and can be rotated safely.
d1b2455 to
945973d
Compare
945973d to
e683c06
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| placeholder={texts.common.email} | ||
| autoFocus | ||
| key={form.key('email')} | ||
| label="username" |
There was a problem hiding this comment.
The email field is labeled as "username", which doesn’t match the actual value being collected (email) and will be announced incorrectly by screen readers. Please use the same localized label used elsewhere (e.g., texts.common.email) or otherwise ensure the accessible name reflects "Email" rather than "username".
| label="username" | |
| label={texts.common.email} |
| <PasswordInput | ||
| placeholder={texts.common.password} | ||
| key={form.key('password')} | ||
| label="password" |
There was a problem hiding this comment.
The password field uses a hard-coded label string ("password") instead of the localized texts.common.password used across the app. This is user-visible and also affects accessibility naming/translation consistency; please switch to the shared texts label (or an equivalent localized string).
| label="password" | |
| label={texts.common.password} |
| uses: ./.github/workflows/build-container-images.yaml | ||
|
|
||
| accessibility_scanner: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Convert Netscape cookie jar to Playwright auth_context JSON | ||
| AUTH_CONTEXT=$(awk '!/^#/ && NF { | ||
| printf "{\"name\":\"%s\",\"value\":\"%s\",\"domain\":\"%s\",\"path\":\"%s\",\"secure\":%s,\"httpOnly\":%s}\n", | ||
| $6, $7, $1, $3, ($4=="TRUE"?"true":"false"), ($2=="TRUE"?"true":"false") | ||
| }' /tmp/cookies.txt | jq -sc '{ | ||
| username: "${{ env.AUTH_INITIAL_ADMIN_USERNAME }}", | ||
| password: "${{ env.AUTH_INITIAL_ADMIN_PASSWORD }}", | ||
| cookies: ., | ||
| localStorage: {} | ||
| }') |
There was a problem hiding this comment.
The auth cookie extraction drops any lines starting with # (awk '!/^#/'). curl writes HttpOnly cookies in the Netscape cookie jar as lines prefixed with #HttpOnly_..., so this will likely discard the session cookie entirely and the scanner will run unauthenticated. Adjust the parsing to retain #HttpOnly_ cookie lines (stripping the prefix) and derive httpOnly correctly (the 2nd Netscape field is the include-subdomains flag, not HttpOnly).
| placeholder={texts.common.email} | ||
| autoFocus | ||
| key={form.key('email')} | ||
| label="username" |
There was a problem hiding this comment.
The TextInput is bound to the email field (getInputProps('email') and placeholder uses texts.common.email), but the accessible label is set to the hard-coded string "username". This will be announced incorrectly by screen readers and is inconsistent with the rest of the UI that uses localized texts.* labels. Use an email-appropriate, localized label (and optionally visually-hide it if you don't want a visible label).
| label="username" | |
| label={texts.common.email} |
d4a2c53 to
7f4b76c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - a11y-scanner-setup | ||
|
|
||
| jobs: | ||
| build: |
There was a problem hiding this comment.
This workflow calls the reusable build-container-images.yaml, which requests packages: write for the job. Since reusable workflow permissions are capped by the caller, add workflow-level (or jobs.build) permissions (e.g., packages: write) to avoid the build job running with default token permissions and failing to authenticate/cache/upload as expected.
| build: | |
| build: | |
| permissions: | |
| contents: read | |
| packages: write |
| ' /tmp/cookies.txt | jq -sc '{ | ||
| username: "${{ env.AUTH_INITIAL_ADMIN_USERNAME }}", | ||
| password: "${{ env.AUTH_INITIAL_ADMIN_PASSWORD }}", | ||
| cookies: ., | ||
| localStorage: {} | ||
| }') | ||
|
|
||
| echo "auth_context=$AUTH_CONTEXT" >> "$GITHUB_OUTPUT" |
There was a problem hiding this comment.
auth_context is built to include username and password and then written to a step output. Even if the password is not a GitHub secret here, embedding credentials in outputs increases the chance of accidental exposure (logs/action debug output) and isn't necessary if cookie-based auth is sufficient. Consider omitting credentials from auth_context and only passing the minimal cookie/storage state required for Playwright.
| - name: Verify axe timing issue (immediate vs after wait) | ||
| run: | | ||
| npm install playwright @axe-core/playwright | ||
| npx playwright install chromium --with-deps | ||
|
|
||
| node << 'SCRIPT' | ||
| const { chromium } = require('playwright'); | ||
| const { default: AxeBuilder } = require('@axe-core/playwright'); | ||
| const AUTH_CONTEXT = JSON.parse(process.env.AUTH_CONTEXT); | ||
|
|
||
| (async () => { | ||
| const browser = await chromium.launch(); | ||
| const context = await browser.newContext({ | ||
| storageState: { | ||
| cookies: AUTH_CONTEXT.cookies.map(c => ({ ...c, sameSite: 'Lax', expires: -1 })), | ||
| origins: [] | ||
| } | ||
| }); | ||
|
|
||
| const urls = [ | ||
| 'http://localhost:3080/admin/theme', | ||
| 'http://localhost:3080/admin/dashboard', | ||
| 'http://localhost:3080/chat' | ||
| ]; | ||
|
|
||
| for (const url of urls) { | ||
| const page = await context.newPage(); | ||
| await page.goto(url); | ||
|
|
||
| const immediate = await new AxeBuilder({ page }).analyze(); | ||
| console.log(`\n=== ${url} ===`); | ||
| console.log('Without wait:', immediate.violations.map(v => v.id)); | ||
|
|
||
| await page.waitForLoadState('networkidle'); | ||
| const waited = await new AxeBuilder({ page }).analyze(); | ||
| console.log('With wait: ', waited.violations.map(v => v.id)); | ||
|
|
||
| await page.close(); | ||
| } | ||
|
|
||
| await browser.close(); | ||
| })(); | ||
| SCRIPT | ||
| env: | ||
| AUTH_CONTEXT: ${{ steps.auth.outputs.auth_context }} | ||
|
|
There was a problem hiding this comment.
The "Verify axe timing issue" step installs Playwright/Axe at runtime via npm install without pinning versions or using actions/setup-node/npm ci with a lockfile, which can make the workflow slow and non-reproducible. If this is only for debugging, remove it before merging; otherwise, align it with the repo’s E2E workflow pattern (setup-node + cached npm ci + pinned deps).
| - name: Verify axe timing issue (immediate vs after wait) | |
| run: | | |
| npm install playwright @axe-core/playwright | |
| npx playwright install chromium --with-deps | |
| node << 'SCRIPT' | |
| const { chromium } = require('playwright'); | |
| const { default: AxeBuilder } = require('@axe-core/playwright'); | |
| const AUTH_CONTEXT = JSON.parse(process.env.AUTH_CONTEXT); | |
| (async () => { | |
| const browser = await chromium.launch(); | |
| const context = await browser.newContext({ | |
| storageState: { | |
| cookies: AUTH_CONTEXT.cookies.map(c => ({ ...c, sameSite: 'Lax', expires: -1 })), | |
| origins: [] | |
| } | |
| }); | |
| const urls = [ | |
| 'http://localhost:3080/admin/theme', | |
| 'http://localhost:3080/admin/dashboard', | |
| 'http://localhost:3080/chat' | |
| ]; | |
| for (const url of urls) { | |
| const page = await context.newPage(); | |
| await page.goto(url); | |
| const immediate = await new AxeBuilder({ page }).analyze(); | |
| console.log(`\n=== ${url} ===`); | |
| console.log('Without wait:', immediate.violations.map(v => v.id)); | |
| await page.waitForLoadState('networkidle'); | |
| const waited = await new AxeBuilder({ page }).analyze(); | |
| console.log('With wait: ', waited.violations.map(v => v.id)); | |
| await page.close(); | |
| } | |
| await browser.close(); | |
| })(); | |
| SCRIPT | |
| env: | |
| AUTH_CONTEXT: ${{ steps.auth.outputs.auth_context }} |
No description provided.