Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 155 additions & 2 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,159 @@ jobs:
if: always()
run: bash scripts/e2e-summary.sh "E2E Test Results" "" "playwright-report"

# ==========================================================================
# ACCESSIBILITY AUDIT (Lighthouse CI)
# Runs Lighthouse against the pre-built dist/ artifact and enforces an
# accessibility score threshold of ≥ 90 %. Results are uploaded to
# temporary public storage and posted as a PR comment so developers get
# immediate feedback without having to download any artifact.
# ==========================================================================
accessibility-audit:
name: Accessibility Audit (Lighthouse)
runs-on: ubuntu-latest
timeout-minutes: 10
needs: build
if: github.event_name != 'pull_request' || github.event.action != 'closed'

steps:
- uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

- name: Download build artifact
uses: actions/download-artifact@v8
with:
name: build

- name: Install Lighthouse CI
run: npm install -g @lhci/cli@0.14.x

- name: Run Lighthouse CI
id: lhci
run: |
set +e
lhci autorun --config=lighthouserc.yml 2>&1 | tee /tmp/lhci-output.txt
echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
continue-on-error: true

- name: Extract Lighthouse results
id: lhci-results
if: always()
run: |
if [ -f .lighthouseci/manifest.json ]; then
SCORE=$(jq -r '(.[0].summary.accessibility * 100 | floor) // empty' .lighthouseci/manifest.json 2>/dev/null || true)
if [[ "$SCORE" =~ ^[0-9]+$ ]]; then
echo "score=$SCORE" >> $GITHUB_OUTPUT
if [ "$SCORE" -ge 90 ]; then
echo "passed=true" >> $GITHUB_OUTPUT
else
echo "passed=false" >> $GITHUB_OUTPUT
fi
else
echo "score=N/A" >> $GITHUB_OUTPUT
echo "passed=false" >> $GITHUB_OUTPUT
fi
else
echo "score=N/A" >> $GITHUB_OUTPUT
echo "passed=false" >> $GITHUB_OUTPUT
fi
Comment thread
Copilot marked this conversation as resolved.
REPORT_URL=$(grep -oP 'https://storage\.googleapis\.com\S+' /tmp/lhci-output.txt 2>/dev/null | head -1 || echo "")
echo "report_url=$REPORT_URL" >> $GITHUB_OUTPUT

- name: Upload Lighthouse report
uses: actions/upload-artifact@v7
if: always()
with:
name: lighthouse-report
path: .lighthouseci/
retention-days: 7

- name: Lighthouse Accessibility Summary
if: always()
run: |
SCORE="${{ steps.lhci-results.outputs.score }}"
PASSED="${{ steps.lhci-results.outputs.passed }}"
REPORT_URL="${{ steps.lhci-results.outputs.report_url }}"

echo "## 🔦 Lighthouse Accessibility Audit" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "$PASSED" == "true" ]]; then
echo "### ✅ Accessibility Threshold Met" >> $GITHUB_STEP_SUMMARY
else
echo "### ❌ Accessibility Threshold Not Met" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Category | Score | Threshold |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|-----------|" >> $GITHUB_STEP_SUMMARY
echo "| ♿ Accessibility | ${SCORE}% | ≥ 90% |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -n "$REPORT_URL" ]; then
echo "🔗 **Full Report:** [View on Temporary Storage]($REPORT_URL) _(expires in 7 days)_" >> $GITHUB_STEP_SUMMARY
fi
echo "📊 **Artifact:** Download the \`lighthouse-report\` artifact for the HTML report." >> $GITHUB_STEP_SUMMARY

- name: Post Lighthouse score as PR comment
if: github.event_name == 'pull_request'
uses: actions/github-script@v9
with:
script: |
const score = '${{ steps.lhci-results.outputs.score }}'
const passed = '${{ steps.lhci-results.outputs.passed }}' === 'true'
const reportUrl = '${{ steps.lhci-results.outputs.report_url }}'
const emoji = passed ? '✅' : '❌'
const status = passed
? 'Threshold (≥ 90%) **met**.'
: '⚠️ Score is **below** the 90% threshold — please fix the violations.'
const reportLine = reportUrl
? `\n\n🔗 [View full Lighthouse report](${reportUrl}) _(expires in 7 days)_`
: '\n\n📊 Download the `lighthouse-report` artifact for details.'

const marker = '<!-- lhci-accessibility-comment -->'
const body = [
marker,
`## ${emoji} Lighthouse Accessibility Score`,
'',
'| Category | Score | Threshold |',
'|----------|-------|-----------|',
`| ♿ Accessibility | ${score}% | ≥ 90% |`,
'',
status + reportLine,
].join('\n')

const { data: comments } = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
})
const existing = comments.find(c => c.body && c.body.includes(marker))

if (existing) {
await github.rest.issues.updateComment({
comment_id: existing.id,
owner: context.repo.owner,
repo: context.repo.repo,
body,
})
} else {
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
})
}

- name: Fail if accessibility threshold not met
if: steps.lhci.outputs.exit_code != '0'
run: |
echo "Lighthouse accessibility score is below the 90% threshold."
echo "See the lighthouse-report artifact or the PR comment for details."
exit 1

# ==========================================================================
# PR INFRASTRUCTURE DEPLOYMENT
# Creates an ephemeral Azure resource group for the PR with all necessary
Expand All @@ -173,7 +326,7 @@ jobs:
name: Deploy PR Infrastructure
runs-on: ubuntu-latest
timeout-minutes: 15
needs: e2e
needs: [e2e, accessibility-audit]
if: github.event_name == 'pull_request' && github.event.action != 'closed'
outputs:
resource_group: ${{ steps.rg.outputs.name }}
Expand Down Expand Up @@ -393,7 +546,7 @@ jobs:
name: Deploy QA Infrastructure
runs-on: ubuntu-latest
timeout-minutes: 15
needs: e2e
needs: [e2e, accessibility-audit]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
outputs:
resource_group: ${{ steps.rg.outputs.name }}
Expand Down
89 changes: 89 additions & 0 deletions e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { test } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
import * as fs from 'fs'
import * as path from 'path'

/**
* Accessibility tests using axe-core / WCAG 2.x rules.
*
* Each test scans a key page and writes all violations to
* playwright-report/a11y/<page>.json so the CI summary script can
* aggregate and report counts by severity.
*
* Tests fail only on critical / serious violations so that moderate /
* minor issues are visible in the report without blocking PRs outright.
*/

const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
const A11Y_REPORT_DIR = path.join('playwright-report', 'a11y')

function saveViolations(pageName: string, violations: object[]): void {
fs.mkdirSync(A11Y_REPORT_DIR, { recursive: true })
fs.writeFileSync(
path.join(A11Y_REPORT_DIR, `${pageName}.json`),
JSON.stringify(violations, null, 2),
)
}

test.describe('Accessibility (axe-core)', () => {
test('home page has no critical or serious violations', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('domcontentloaded')

const { violations } = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze()
saveViolations('home', violations)

const blocking = violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious',
)
if (blocking.length > 0) {
const details = blocking
.map((v) => ` [${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
.join('\n')
throw new Error(
`${blocking.length} critical/serious violation(s) on home page:\n${details}`,
)
}
})

test('create game page has no critical or serious violations', async ({ page }) => {
await page.goto('/')
await page.getByRole('button', { name: /crear nuevo juego|create new game/i }).click()
await page.waitForLoadState('domcontentloaded')

const { violations } = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze()
saveViolations('create-game', violations)

const blocking = violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious',
)
if (blocking.length > 0) {
const details = blocking
.map((v) => ` [${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
.join('\n')
throw new Error(
`${blocking.length} critical/serious violation(s) on create-game page:\n${details}`,
)
}
})

test('privacy page has no critical or serious violations', async ({ page }) => {
await page.goto('/?view=privacy')
await page.waitForLoadState('domcontentloaded')

const { violations } = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze()
saveViolations('privacy', violations)

const blocking = violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious',
)
if (blocking.length > 0) {
const details = blocking
.map((v) => ` [${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
.join('\n')
throw new Error(
`${blocking.length} critical/serious violation(s) on privacy page:\n${details}`,
)
}
})
})
25 changes: 25 additions & 0 deletions lighthouserc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
ci:
collect:
# Serve the pre-built frontend from dist/ via a local static server
staticDistDir: ./dist
numberOfRuns: 1
settings:
# Only run the accessibility category for speed; other categories are
# covered separately by axe-core in Playwright.
onlyCategories:
- accessibility
# Required flags for running headless Chrome in CI (no sandbox / display)
chromeFlags: '--no-sandbox --headless'

assert:
assertions:
# Fail the job when the accessibility score drops below 90%
'categories:accessibility':
- error
- minScore: 0.9
aggregationMethod: pessimistic

upload:
# Upload to Google's temporary public storage so the report URL can be
# shared in the PR comment. Reports expire automatically after 7 days.
target: temporary-public-storage
39 changes: 39 additions & 0 deletions scripts/e2e-summary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,42 @@ fi

echo "" >> "$GITHUB_STEP_SUMMARY"
echo "📊 **Full Report:** Download the \`${ARTIFACT_NAME}\` artifact for detailed HTML report." >> "$GITHUB_STEP_SUMMARY"

# ---------------------------------------------------------------------------
# Accessibility violation summary (populated by e2e/accessibility.spec.ts)
# Each scanned page writes its axe-core violations to
# playwright-report/a11y/<page>.json as a JSON array.
# ---------------------------------------------------------------------------
if ls playwright-report/a11y/*.json 1>/dev/null 2>&1; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ♿ Accessibility Violations (axe-core)" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"

# Count violations by impact level across all scanned pages.
# Use jq -s to read all files at once; default to 0 if parsing fails so the
# summary step doesn't fail the whole job.
sum_impact() {
local impact="$1"
jq -s --arg impact "$impact" '[.[].[] | select(.impact == $impact)] | length' playwright-report/a11y/*.json || echo 0
}

CRITICAL=$(sum_impact critical)
SERIOUS=$(sum_impact serious)
MODERATE=$(sum_impact moderate)
MINOR=$(sum_impact minor)
TOTAL=$((CRITICAL + SERIOUS + MODERATE + MINOR))
Comment thread
Copilot marked this conversation as resolved.

if [ "$TOTAL" -eq 0 ]; then
echo "✅ No accessibility violations found across scanned pages." >> "$GITHUB_STEP_SUMMARY"
else
echo "| Severity | Count |" >> "$GITHUB_STEP_SUMMARY"
echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| 🔴 Critical | $CRITICAL |" >> "$GITHUB_STEP_SUMMARY"
echo "| 🟠 Serious | $SERIOUS |" >> "$GITHUB_STEP_SUMMARY"
echo "| 🟡 Moderate | $MODERATE |" >> "$GITHUB_STEP_SUMMARY"
echo "| 🔵 Minor | $MINOR |" >> "$GITHUB_STEP_SUMMARY"
echo "| **Total** | **$TOTAL** |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "> Critical and serious violations fail the accessibility tests. Download the \`${ARTIFACT_NAME}\` artifact for full axe-core details." >> "$GITHUB_STEP_SUMMARY"
fi
fi
3 changes: 2 additions & 1 deletion src/components/CreateGameView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export function CreateGameView({ onGameCreated, onBack, emailConfigured = false
<Label htmlFor="amount">{t('giftAmount')}</Label>
<div className="flex gap-2">
<Select value={currency} onValueChange={setCurrency}>
<SelectTrigger className="w-[180px]">
<SelectTrigger className="w-[180px]" aria-label={t('selectCurrency')}>
<SelectValue placeholder={t('selectCurrency')} />
</SelectTrigger>
<SelectContent>
Expand Down Expand Up @@ -488,6 +488,7 @@ export function CreateGameView({ onGameCreated, onBack, emailConfigured = false
variant="ghost"
size="sm"
onClick={() => removeParticipant(index)}
aria-label={`${t('removeParticipant')} ${participant.name}`}
>
<X size={16} />
</Button>
Expand Down
Loading
Loading