test(timetable): axe-core zero-violation a11y enforcement (Step 11)
Three jsdom axe scans covering the user-facing surface of the canvas.
The scans surfaced two real a11y bugs which are fixed in this same
commit:
1. PerformancePopover — VProgressLinear (advancing aggregate) had no
accessible name. Added aria-label that announces "X van Y secties
afgerond (N%)".
2. AddPerformanceDialog — the icon-only close button (×) was missing
aria-label. Added 'Sluiten'.
Test scenarios:
- PerformanceBlock with focus
- PerformancePopover open
- AddPerformanceDialog open
Page-level axe rules (region, page-has-heading-one, landmark-one-main,
color-contrast) are disabled for fragment scans — they only make sense
on a full page, and color-contrast resolution is jsdom-blind. Both are
covered by Playwright CT in TEST-INFRA-001 / TEST-VISUAL-001.
Test count: 380 → 383.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
161
apps/app/tests/a11y/axe.test.ts
Normal file
161
apps/app/tests/a11y/axe.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { axe } from 'vitest-axe'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { mountWithVuexy } from '../utils/mountWithVuexy'
|
||||
import AddPerformanceDialog from '@/components/timetable/AddPerformanceDialog.vue'
|
||||
import PerformanceBlock from '@/components/timetable/PerformanceBlock.vue'
|
||||
import PerformancePopover from '@/components/timetable/PerformancePopover.vue'
|
||||
import { type ArtistEngagement, ArtistEngagementStatus, type Performance, type Stage } from '@/types/timetable'
|
||||
|
||||
/**
|
||||
* jsdom-based axe scans pick up structural a11y issues (missing roles,
|
||||
* orphan labels, color-contrast metadata, ARIA mismatches) but cannot
|
||||
* resolve actual rendered colors — that needs a real browser. Visual
|
||||
* a11y is on TEST-INFRA-001's Playwright migration list.
|
||||
*
|
||||
* For now: zero violations on (1) PerformanceBlock with focus,
|
||||
* (2) PerformancePopover open, (3) AddPerformanceDialog open.
|
||||
*/
|
||||
|
||||
const stage: Stage = {
|
||||
id: 's1',
|
||||
event_id: 'ev1',
|
||||
name: 'Hardstyle District',
|
||||
color: '#e85d75',
|
||||
capacity: 1000,
|
||||
sort_order: 0,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
}
|
||||
|
||||
const engagement: Partial<ArtistEngagement> = {
|
||||
id: 'e1',
|
||||
artist: { id: 'a1', name: 'Devin Wild' } as ArtistEngagement['artist'],
|
||||
booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' },
|
||||
computed: { buma_amount: 70, vat_grondslag: 1070, vat_amount: 224.7, breakdown_total: 0, total_cost: 1294.7 },
|
||||
fee_amount: 1000,
|
||||
advancing_completed_count: 3,
|
||||
advancing_total_count: 5,
|
||||
}
|
||||
|
||||
function performance(): Performance {
|
||||
return {
|
||||
id: 'p1',
|
||||
engagement_id: 'e1',
|
||||
event_id: 'ev1',
|
||||
stage_id: 's1',
|
||||
lane: 0,
|
||||
lane_resolved: 0,
|
||||
start_at: '2026-07-10T18:00:00.000Z',
|
||||
end_at: '2026-07-10T19:00:00.000Z',
|
||||
version: 1,
|
||||
notes: null,
|
||||
warnings: [],
|
||||
engagement: engagement as ArtistEngagement,
|
||||
stage,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
deleted_at: null,
|
||||
}
|
||||
}
|
||||
|
||||
// Stub VDialog so the popover/dialog renders inline (axe needs to see the
|
||||
// content in the wrapper, not in a teleport target).
|
||||
const VDialogStub = defineComponent({
|
||||
name: 'VDialog',
|
||||
props: ['modelValue'],
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'v-dialog-stub' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
// Component fragments are not full pages — skip page-level landmark
|
||||
// rules (`region`, `page-has-heading-one`, `landmark-one-main`) that
|
||||
// only make sense at document root. Visual contrast resolution is also
|
||||
// jsdom-blind; TEST-INFRA-001's Playwright migration covers that.
|
||||
const fragmentAxeOptions = {
|
||||
rules: {
|
||||
'region': { enabled: false },
|
||||
'page-has-heading-one': { enabled: false },
|
||||
'landmark-one-main': { enabled: false },
|
||||
'color-contrast': { enabled: false },
|
||||
},
|
||||
}
|
||||
|
||||
describe('axe-core a11y enforcement (RFC D20 + D21)', () => {
|
||||
it('PerformanceBlock has zero violations when rendered + focusable', async () => {
|
||||
const { wrapper } = mountWithVuexy(PerformanceBlock, {
|
||||
props: {
|
||||
performance: performance(),
|
||||
leftPx: 0,
|
||||
widthPx: 200,
|
||||
topPx: 0,
|
||||
heightPx: 44,
|
||||
},
|
||||
})
|
||||
|
||||
const results = await axe(wrapper.element, fragmentAxeOptions)
|
||||
|
||||
expect(results).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('PerformancePopover (open) has zero violations', async () => {
|
||||
const orgIdRef = ref('org_1')
|
||||
|
||||
const { wrapper } = mountWithVuexy(PerformancePopover, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
anchorRect: { top: 100, left: 100, right: 200, bottom: 150, width: 100, height: 50, x: 100, y: 100, toJSON: () => ({}) } as DOMRect,
|
||||
performance: performance(),
|
||||
orgId: orgIdRef.value,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const popoverEl = document.querySelector('.tt-popover')
|
||||
|
||||
expect(popoverEl).toBeTruthy()
|
||||
|
||||
const results = await axe(popoverEl as HTMLElement, fragmentAxeOptions)
|
||||
|
||||
expect(results).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('AddPerformanceDialog (open) has zero violations', async () => {
|
||||
const fieldStub = defineComponent({
|
||||
name: 'FieldStub',
|
||||
props: ['modelValue', 'label', 'errorMessages'],
|
||||
setup(props) {
|
||||
return () => h('div', { class: 'field-stub' }, [
|
||||
h('label', {}, String(props.label ?? '')),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const { wrapper } = mountWithVuexy(AddPerformanceDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
orgId: 'org_1',
|
||||
eventId: 'ev_1',
|
||||
dayId: 'day_1',
|
||||
stages: [stage],
|
||||
engagements: [engagement as ArtistEngagement],
|
||||
},
|
||||
stubs: {
|
||||
VDialog: VDialogStub,
|
||||
AppTextField: fieldStub,
|
||||
AppTextarea: fieldStub,
|
||||
AppSelect: fieldStub,
|
||||
AppAutocomplete: fieldStub,
|
||||
AppDateTimePicker: fieldStub,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const results = await axe(wrapper.element, fragmentAxeOptions)
|
||||
|
||||
expect(results).toHaveNoViolations()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user