forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1/**
2 * This test ensures all Vue components in app/components/ have accessibility tests.
3 *
4 * When this test fails, it means a new component was added without corresponding
5 * accessibility tests in test/nuxt/a11y.spec.ts.
6 *
7 * To fix:
8 * 1. Add the component import to test/nuxt/a11y.spec.ts
9 * 2. Add a describe block with at least one axe accessibility test for the component
10 */
11import fs from 'node:fs'
12import path from 'node:path'
13import { assert, describe, it } from 'vitest'
14import { fileURLToPath } from 'node:url'
15
16/**
17 * Components explicitly skipped from a11y testing with reasons.
18 * Add components here only with a valid justification.
19 *
20 * Note: Tests in test/nuxt/a11y.spec.ts run in a real browser environment,
21 * so client components can be tested directly. When importing `SomeComponent`
22 * from #components, it counts as testing `SomeComponent.client.vue` if it exists.
23 */
24const SKIPPED_COMPONENTS: Record<string, string> = {
25 // OgImage components are server-side rendered images, not interactive UI
26 'OgImage/Default.vue': 'OG Image component - server-rendered image, not interactive UI',
27 'OgImage/Package.vue': 'OG Image component - server-rendered image, not interactive UI',
28
29 // Client-only components with complex dependencies
30 'Header/AuthModal.client.vue': 'Complex auth modal with navigation - requires full app context',
31
32 // Complex components requiring full app context or specific runtime conditions
33 'Header/OrgsDropdown.vue': 'Requires connector context and API calls',
34 'Header/PackagesDropdown.vue': 'Requires connector context and API calls',
35 'Header/MobileMenu.client.vue': 'Requires Teleport and full navigation context',
36 'Modal.client.vue':
37 'Base modal component - tested via specific modals like ChartModal, ConnectorModal',
38 'Package/SkillsModal.vue': 'Complex modal with tabs - requires modal context and state',
39 'ScrollToTop.client.vue': 'Requires scroll position and CSS scroll-state queries',
40 'Settings/TranslationHelper.vue': 'i18n helper component - requires specific locale status data',
41 'Package/WeeklyDownloadStats.vue':
42 'Uses vue-data-ui VueUiSparkline - has DOM measurement issues in test environment',
43 'Package/VersionDistribution.vue':
44 'Uses vue-data-ui VueUiXy - has DOM measurement issues in test environment',
45 'UserCombobox.vue': 'Unused component - intended for future admin features',
46 'SkeletonBlock.vue': 'Already covered indirectly via other component tests',
47 'SkeletonInline.vue': 'Already covered indirectly via other component tests',
48 'Button/Group.vue': "Wrapper component, tests wouldn't make much sense here",
49}
50
51/**
52 * Recursively get all Vue component files in a directory.
53 */
54function getVueFiles(dir: string, baseDir: string = dir): string[] {
55 const files: string[] = []
56 const entries = fs.readdirSync(dir, { withFileTypes: true })
57
58 for (const entry of entries) {
59 const fullPath = path.join(dir, entry.name)
60 if (entry.isDirectory()) {
61 files.push(...getVueFiles(fullPath, baseDir))
62 } else if (entry.isFile() && entry.name.endsWith('.vue')) {
63 // Get relative path from base components directory
64 files.push(path.relative(baseDir, fullPath))
65 }
66 }
67
68 return files
69}
70
71/**
72 * Parse .nuxt/components.d.ts to get the mapping from component names to file paths.
73 * This uses Nuxt's actual component resolution, so we don't have to guess the naming convention.
74 *
75 * Returns a Map of component name -> array of file paths (relative to app/components/)
76 */
77function parseComponentsDeclaration(dtsPath: string): Map<string, string[]> {
78 const content = fs.readFileSync(dtsPath, 'utf-8')
79 const componentMap = new Map<string, string[]>()
80
81 // Match lines like:
82 // export const ComponentName: typeof import("../app/components/Path/File.vue").default
83 const exportRegex =
84 /export const (\w+): typeof import\("\.\.\/app\/components\/([^"]+\.vue)"\)\.default/g
85
86 let match
87 while ((match = exportRegex.exec(content)) !== null) {
88 const componentName = match[1]!
89 const filePath = match[2]!
90
91 const existing = componentMap.get(componentName) || []
92 if (!existing.includes(filePath)) {
93 existing.push(filePath)
94 }
95 componentMap.set(componentName, existing)
96 }
97
98 return componentMap
99}
100
101/**
102 * Extract tested component names from the test file.
103 * Handles both #components imports and direct ~/components/ imports.
104 */
105function getTestedComponents(
106 testFileContent: string,
107 componentMap: Map<string, string[]>,
108): Set<string> {
109 const tested = new Set<string>()
110
111 // Match direct imports like:
112 // import ComponentName from '~/components/ComponentName.vue'
113 // import ComponentName from '~/components/subdir/ComponentName.vue'
114 const directImportRegex = /import\s+\w+\s+from\s+['"]~\/components\/([^"']+\.vue)['"]/g
115 let match
116
117 while ((match = directImportRegex.exec(testFileContent)) !== null) {
118 tested.add(match[1]!)
119 }
120
121 // Match #components imports like:
122 // import { ComponentName, OtherComponent } from '#components'
123 const hashComponentsRegex = /import\s*\{([^}]+)\}\s*from\s*['"]#components['"]/g
124 while ((match = hashComponentsRegex.exec(testFileContent)) !== null) {
125 const importList = match[1]!
126 // Parse the import list, handling multi-line imports
127 const componentNames = importList
128 .split(',')
129 .map(name => name.trim())
130 .filter(name => name.length > 0)
131
132 for (const name of componentNames) {
133 // Look up the file paths from Nuxt's component map
134 const filePaths = componentMap.get(name) || []
135 for (const filePath of filePaths) {
136 tested.add(filePath)
137 }
138 }
139 }
140
141 return tested
142}
143
144describe('a11y component test coverage', () => {
145 const componentsDir = fileURLToPath(new URL('../../app/components', import.meta.url))
146 const componentsDtsPath = fileURLToPath(new URL('../../.nuxt/components.d.ts', import.meta.url))
147 const testFilePath = fileURLToPath(new URL('../nuxt/a11y.spec.ts', import.meta.url))
148
149 it('should have accessibility tests for all components (or be explicitly skipped)', () => {
150 // Get all Vue components
151 const allComponents = getVueFiles(componentsDir)
152
153 // Parse Nuxt's component declarations to get name -> path mapping
154 const componentMap = parseComponentsDeclaration(componentsDtsPath)
155
156 // Get components that are tested
157 const testFileContent = fs.readFileSync(testFilePath, 'utf-8')
158 const testedComponents = getTestedComponents(testFileContent, componentMap)
159
160 // Find components that are neither tested nor skipped
161 const missingTests = allComponents.filter(
162 component => !testedComponents.has(component) && !SKIPPED_COMPONENTS[component],
163 )
164
165 // Fail with helpful message if any components are missing tests
166 assert.strictEqual(missingTests.length, 0, buildMissingTestsMessage(missingTests))
167 })
168
169 it('should not have obsolete entries in SKIPPED_COMPONENTS', () => {
170 const allComponents = getVueFiles(componentsDir)
171 const componentSet = new Set(allComponents)
172
173 const obsoleteSkips = Object.keys(SKIPPED_COMPONENTS).filter(
174 component => !componentSet.has(component),
175 )
176
177 assert.strictEqual(obsoleteSkips.length, 0, buildObsoleteSkipsMessage(obsoleteSkips))
178 })
179
180 it('should not skip components that are actually tested', () => {
181 const componentMap = parseComponentsDeclaration(componentsDtsPath)
182 const testFileContent = fs.readFileSync(testFilePath, 'utf-8')
183 const testedComponents = getTestedComponents(testFileContent, componentMap)
184
185 const unnecessarySkips = Object.keys(SKIPPED_COMPONENTS).filter(component =>
186 testedComponents.has(component),
187 )
188
189 assert.strictEqual(unnecessarySkips.length, 0, buildUnnecessarySkipsMessage(unnecessarySkips))
190 })
191})
192
193function buildMissingTestsMessage(missingTests: string[]): string {
194 if (missingTests.length === 0) return ''
195 return (
196 `Missing a11y tests for ${missingTests.length} component(s):\n` +
197 missingTests.map(c => ` - ${c}`).join('\n') +
198 '\n\nTo fix: Add tests in test/nuxt/a11y.spec.ts or add to SKIPPED_COMPONENTS ' +
199 'in test/unit/a11y-component-coverage.spec.ts with justification.'
200 )
201}
202
203function buildObsoleteSkipsMessage(obsoleteSkips: string[]): string {
204 if (obsoleteSkips.length === 0) return ''
205 return (
206 `Obsolete SKIPPED_COMPONENTS entries:\n` +
207 obsoleteSkips.map(c => ` - ${c}`).join('\n') +
208 '\n\nThese components no longer exist. Remove them from SKIPPED_COMPONENTS.'
209 )
210}
211
212function buildUnnecessarySkipsMessage(unnecessarySkips: string[]): string {
213 if (unnecessarySkips.length === 0) return ''
214 return (
215 `Unnecessary SKIPPED_COMPONENTS entries:\n` +
216 unnecessarySkips.map(c => ` - ${c}`).join('\n') +
217 '\n\nThese components have tests now. Remove them from SKIPPED_COMPONENTS.'
218 )
219}