Skip to content

Commit 0c2550f

Browse files
committed
test: add basic axe component tests
1 parent 091b63f commit 0c2550f

3 files changed

Lines changed: 252 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"@vitest/browser-playwright": "^4.0.18",
6767
"@vitest/coverage-v8": "^4.0.18",
6868
"@vue/test-utils": "2.4.6",
69+
"axe-core": "^4.11.1",
6970
"happy-dom": "20.3.5",
7071
"lint-staged": "16.2.7",
7172
"marked": "17.0.1",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/nuxt/components.spec.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import type { AxeResults, RunOptions } from 'axe-core'
2+
import type { VueWrapper } from '@vue/test-utils'
3+
import 'axe-core'
4+
import { afterEach, describe, expect, it } from 'vitest'
5+
import { mountSuspended } from '@nuxt/test-utils/runtime'
6+
7+
// axe-core is a UMD module that exposes itself as window.axe in the browser
8+
declare const axe: { run: (context: Element, options?: RunOptions) => Promise<AxeResults> }
9+
10+
// Track mounted containers for cleanup
11+
const mountedContainers: HTMLElement[] = []
12+
13+
/**
14+
* Run axe accessibility audit on a mounted component.
15+
* Mounts the component in an isolated container to avoid cross-test pollution.
16+
*/
17+
async function runAxe(wrapper: VueWrapper): Promise<AxeResults> {
18+
// Create an isolated container for this test
19+
const container = document.createElement('div')
20+
container.id = `test-container-${Date.now()}`
21+
document.body.appendChild(container)
22+
mountedContainers.push(container)
23+
24+
// Clone the element into our isolated container
25+
const el = wrapper.element.cloneNode(true) as HTMLElement
26+
container.appendChild(el)
27+
28+
// Run axe only on the isolated container
29+
return axe.run(container, {
30+
// Disable rules that don't apply to isolated component testing
31+
rules: {
32+
// These rules check page-level concerns that don't apply to isolated components
33+
'landmark-one-main': { enabled: false },
34+
'region': { enabled: false },
35+
'page-has-heading-one': { enabled: false },
36+
// Duplicate landmarks are expected when testing multiple header/footer components
37+
'landmark-no-duplicate-banner': { enabled: false },
38+
'landmark-no-duplicate-contentinfo': { enabled: false },
39+
'landmark-no-duplicate-main': { enabled: false },
40+
},
41+
})
42+
}
43+
44+
// Clean up mounted containers after each test
45+
afterEach(() => {
46+
for (const container of mountedContainers) {
47+
container.remove()
48+
}
49+
mountedContainers.length = 0
50+
})
51+
52+
import AppHeader from '~/components/AppHeader.vue'
53+
import AppFooter from '~/components/AppFooter.vue'
54+
import AppTooltip from '~/components/AppTooltip.vue'
55+
import LoadingSpinner from '~/components/LoadingSpinner.vue'
56+
import JsrBadge from '~/components/JsrBadge.vue'
57+
import ProvenanceBadge from '~/components/ProvenanceBadge.vue'
58+
import MarkdownText from '~/components/MarkdownText.vue'
59+
import PackageSkeleton from '~/components/PackageSkeleton.vue'
60+
import PackageCard from '~/components/PackageCard.vue'
61+
62+
describe('component accessibility audits', () => {
63+
describe('AppHeader', () => {
64+
it('should have no accessibility violations', async () => {
65+
const component = await mountSuspended(AppHeader)
66+
const results = await runAxe(component)
67+
expect(results.violations).toEqual([])
68+
})
69+
70+
it('should have no accessibility violations without logo', async () => {
71+
const component = await mountSuspended(AppHeader, {
72+
props: { showLogo: false },
73+
})
74+
const results = await runAxe(component)
75+
expect(results.violations).toEqual([])
76+
})
77+
78+
it('should have no accessibility violations without connector', async () => {
79+
const component = await mountSuspended(AppHeader, {
80+
props: { showConnector: false },
81+
})
82+
const results = await runAxe(component)
83+
expect(results.violations).toEqual([])
84+
})
85+
})
86+
87+
describe('AppFooter', () => {
88+
it('should have no accessibility violations', async () => {
89+
const component = await mountSuspended(AppFooter)
90+
const results = await runAxe(component)
91+
expect(results.violations).toEqual([])
92+
})
93+
})
94+
95+
describe('AppTooltip', () => {
96+
it('should have no accessibility violations', async () => {
97+
const component = await mountSuspended(AppTooltip, {
98+
props: { text: 'Tooltip content' },
99+
slots: { default: '<button>Trigger</button>' },
100+
})
101+
const results = await runAxe(component)
102+
expect(results.violations).toEqual([])
103+
})
104+
})
105+
106+
describe('LoadingSpinner', () => {
107+
it('should have no accessibility violations', async () => {
108+
const component = await mountSuspended(LoadingSpinner)
109+
const results = await runAxe(component)
110+
expect(results.violations).toEqual([])
111+
})
112+
113+
it('should have no accessibility violations with custom text', async () => {
114+
const component = await mountSuspended(LoadingSpinner, {
115+
props: { text: 'Fetching data...' },
116+
})
117+
const results = await runAxe(component)
118+
expect(results.violations).toEqual([])
119+
})
120+
})
121+
122+
describe('JsrBadge', () => {
123+
it('should have no accessibility violations', async () => {
124+
const component = await mountSuspended(JsrBadge, {
125+
props: { url: 'https://jsr.io/@std/fs' },
126+
})
127+
const results = await runAxe(component)
128+
expect(results.violations).toEqual([])
129+
})
130+
131+
it('should have no accessibility violations in compact mode', async () => {
132+
const component = await mountSuspended(JsrBadge, {
133+
props: { url: 'https://jsr.io/@std/fs', compact: true },
134+
})
135+
const results = await runAxe(component)
136+
expect(results.violations).toEqual([])
137+
})
138+
})
139+
140+
describe('ProvenanceBadge', () => {
141+
it('should have no accessibility violations without link', async () => {
142+
const component = await mountSuspended(ProvenanceBadge)
143+
const results = await runAxe(component)
144+
expect(results.violations).toEqual([])
145+
})
146+
147+
it('should have no accessibility violations with link', async () => {
148+
const component = await mountSuspended(ProvenanceBadge, {
149+
props: {
150+
provider: 'github',
151+
packageName: 'vue',
152+
version: '3.0.0',
153+
},
154+
})
155+
const results = await runAxe(component)
156+
expect(results.violations).toEqual([])
157+
})
158+
159+
it('should have no accessibility violations in compact mode', async () => {
160+
const component = await mountSuspended(ProvenanceBadge, {
161+
props: {
162+
provider: 'github',
163+
packageName: 'vue',
164+
version: '3.0.0',
165+
compact: true,
166+
},
167+
})
168+
const results = await runAxe(component)
169+
expect(results.violations).toEqual([])
170+
})
171+
})
172+
173+
describe('MarkdownText', () => {
174+
it('should have no accessibility violations with plain text', async () => {
175+
const component = await mountSuspended(MarkdownText, {
176+
props: { text: 'Simple text' },
177+
})
178+
const results = await runAxe(component)
179+
expect(results.violations).toEqual([])
180+
})
181+
182+
it('should have no accessibility violations with formatted text', async () => {
183+
const component = await mountSuspended(MarkdownText, {
184+
props: { text: '**Bold** and *italic* and `code`' },
185+
})
186+
const results = await runAxe(component)
187+
expect(results.violations).toEqual([])
188+
})
189+
})
190+
191+
describe('PackageSkeleton', () => {
192+
it('should have no accessibility violations', async () => {
193+
const component = await mountSuspended(PackageSkeleton)
194+
const results = await runAxe(component)
195+
// PackageSkeleton uses empty h1/h2 elements as skeleton placeholders.
196+
// These are expected since the component represents a loading state.
197+
// The real content will have proper heading text when loaded.
198+
// Filter out 'empty-heading' violations as they're expected for skeleton components.
199+
const violations = results.violations.filter(v => v.id !== 'empty-heading')
200+
expect(violations).toEqual([])
201+
})
202+
})
203+
204+
describe('PackageCard', () => {
205+
const mockResult = {
206+
package: {
207+
name: 'vue',
208+
version: '3.5.0',
209+
description: 'The progressive JavaScript framework',
210+
date: '2024-01-15T00:00:00.000Z',
211+
keywords: ['framework', 'frontend', 'reactive'],
212+
links: {},
213+
publisher: {
214+
username: 'yyx990803',
215+
},
216+
},
217+
score: {
218+
final: 0.9,
219+
detail: { quality: 0.9, popularity: 0.9, maintenance: 0.9 },
220+
},
221+
searchScore: 100000,
222+
}
223+
224+
it('should have no accessibility violations', async () => {
225+
const component = await mountSuspended(PackageCard, {
226+
props: { result: mockResult },
227+
})
228+
const results = await runAxe(component)
229+
expect(results.violations).toEqual([])
230+
})
231+
232+
it('should have no accessibility violations with h2 heading', async () => {
233+
const component = await mountSuspended(PackageCard, {
234+
props: { result: mockResult, headingLevel: 'h2' },
235+
})
236+
const results = await runAxe(component)
237+
expect(results.violations).toEqual([])
238+
})
239+
240+
it('should have no accessibility violations showing publisher', async () => {
241+
const component = await mountSuspended(PackageCard, {
242+
props: { result: mockResult, showPublisher: true },
243+
})
244+
const results = await runAxe(component)
245+
expect(results.violations).toEqual([])
246+
})
247+
})
248+
})

0 commit comments

Comments
 (0)