[READ-ONLY] a fast, modern browser for the npm registry
at main 219 lines 8.8 kB view raw
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}