From 66a6f7ddc347d0f9b0dd6cde3f370528a562f762 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 03:53:16 +0200 Subject: [PATCH] test(timetable): axe-core zero-violation a11y enforcement (Step 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../timetable/AddPerformanceDialog.vue | 1 + .../timetable/PerformancePopover.vue | 1 + apps/app/tests/a11y/axe.test.ts | 161 ++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 apps/app/tests/a11y/axe.test.ts diff --git a/apps/app/src/components/timetable/AddPerformanceDialog.vue b/apps/app/src/components/timetable/AddPerformanceDialog.vue index a1e3d2a4..551b6dba 100644 --- a/apps/app/src/components/timetable/AddPerformanceDialog.vue +++ b/apps/app/src/components/timetable/AddPerformanceDialog.vue @@ -117,6 +117,7 @@ defineExpose({ form, errors, submit }) icon="tabler-x" variant="text" size="small" + aria-label="Sluiten" @click="emit('update:modelValue', false)" /> diff --git a/apps/app/src/components/timetable/PerformancePopover.vue b/apps/app/src/components/timetable/PerformancePopover.vue index 1d24ec6f..018141d5 100644 --- a/apps/app/src/components/timetable/PerformancePopover.vue +++ b/apps/app/src/components/timetable/PerformancePopover.vue @@ -113,6 +113,7 @@ function close(): void { = { + 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() + }) +})