[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: add a11y tests for more components

+698 -52
+4 -2
CONTRIBUTING.md
··· 409 409 410 410 ### Component accessibility tests 411 411 412 - All new components should have a basic accessibility test in `test/nuxt/components.spec.ts`. These tests use [axe-core](https://github.com/dequelabs/axe-core) to catch common accessibility violations. 412 + All Vue components should have accessibility tests in `test/nuxt/a11y.spec.ts`. These tests use [axe-core](https://github.com/dequelabs/axe-core) to catch common accessibility violations and run in a real browser environment via Playwright. 413 413 414 414 ```typescript 415 - import MyComponent from '~/components/MyComponent.vue' 415 + import { MyComponent } from '#components' 416 416 417 417 describe('MyComponent', () => { 418 418 it('should have no accessibility violations', async () => { ··· 428 428 ``` 429 429 430 430 The `runAxe` helper handles DOM isolation and disables page-level rules that don't apply to isolated component testing. 431 + 432 + A coverage test in `test/unit/a11y-component-coverage.spec.ts` ensures all components are either tested or explicitly skipped with justification. When you add a new component, this test will fail until you add accessibility tests for it. 431 433 432 434 > [!IMPORTANT] 433 435 > Just because axe-core doesn't find any obvious issues, it does not mean a component is accessible. Please do additional checks and use best practices.
+497 -50
test/nuxt/components.spec.ts test/nuxt/a11y.spec.ts
··· 52 52 mountedContainers.length = 0 53 53 }) 54 54 55 - import DateTime from '~/components/DateTime.vue' 56 - import AppHeader from '~/components/AppHeader.vue' 57 - import AppFooter from '~/components/AppFooter.vue' 58 - import AppTooltip from '~/components/AppTooltip.vue' 59 - import AnnounceTooltip from '~/components/AnnounceTooltip.vue' 60 - import LoadingSpinner from '~/components/LoadingSpinner.vue' 61 - import JsrBadge from '~/components/JsrBadge.vue' 62 - import ProvenanceBadge from '~/components/ProvenanceBadge.vue' 63 - import MarkdownText from '~/components/MarkdownText.vue' 64 - import PackageSkeleton from '~/components/PackageSkeleton.vue' 65 - import PackageCard from '~/components/PackageCard.vue' 66 - import ChartModal from '~/components/ChartModal.vue' 67 - import PackageDownloadAnalytics from '~/components/PackageDownloadAnalytics.vue' 68 - import PackagePlaygrounds from '~/components/PackagePlaygrounds.vue' 69 - import PackageDependencies from '~/components/PackageDependencies.vue' 70 - import PackageVersions from '~/components/PackageVersions.vue' 71 - import PackageListControls from '~/components/PackageListControls.vue' 72 - import PackageMaintainers from '~/components/PackageMaintainers.vue' 73 - import CodeViewer from '~/components/CodeViewer.vue' 74 - import CodeDirectoryListing from '~/components/CodeDirectoryListing.vue' 75 - import CodeFileTree from '~/components/CodeFileTree.vue' 76 - import UserCombobox from '~/components/UserCombobox.vue' 77 - import ConnectorModal from '~/components/ConnectorModal.vue' 55 + // Import components from #components where possible 56 + // For server/client variants, we need to import directly to test the specific variant 57 + import { 58 + AccentColorPicker, 59 + AnnounceTooltip, 60 + AppFooter, 61 + AppHeader, 62 + AppTooltip, 63 + BaseTooltip, 64 + BuildEnvironment, 65 + ChartModal, 66 + ClaimPackageModal, 67 + CodeDirectoryListing, 68 + CodeFileTree, 69 + CodeMobileTreeDrawer, 70 + CodeViewer, 71 + CollapsibleSection, 72 + ColumnPicker, 73 + CompareFacetCard, 74 + CompareFacetRow, 75 + CompareFacetSelector, 76 + CompareComparisonGrid, 77 + ComparePackageSelector, 78 + ConnectorModal, 79 + DateTime, 80 + DependencyPathPopup, 81 + ExecuteCommandTerminal, 82 + FilterChips, 83 + FilterPanel, 84 + HeaderAccountMenu, 85 + InstallCommandTerminal, 86 + JsrBadge, 87 + LicenseDisplay, 88 + LoadingSpinner, 89 + MarkdownText, 90 + OperationsQueue, 91 + OrgMembersPanel, 92 + OrgTeamsPanel, 93 + PackageAccessControls, 94 + PackageCard, 95 + PackageDependencies, 96 + PackageDeprecatedTree, 97 + PackageDownloadAnalytics, 98 + PackageInstallScripts, 99 + PackageList, 100 + PackageListControls, 101 + PackageListToolbar, 102 + PackageMaintainers, 103 + PackageManagerSelect, 104 + PackageMetricsBadges, 105 + PackagePlaygrounds, 106 + PackageReplacement, 107 + PackageSkeleton, 108 + PackageSkillsCard, 109 + PackageTable, 110 + PackageTableRow, 111 + PackageVersions, 112 + PackageVulnerabilityTree, 113 + PaginationControls, 114 + ProvenanceBadge, 115 + Readme, 116 + SearchBox, 117 + SearchSuggestionCard, 118 + Toggle, 119 + UserCombobox, 120 + VersionSelector, 121 + ViewModeToggle, 122 + } from '#components' 123 + 124 + // Server variant components must be imported directly to test the server-side render 125 + // The #components import automatically provides the client variant 126 + import AuthButtonServer from '~/components/AuthButton.server.vue' 78 127 import HeaderAccountMenuServer from '~/components/HeaderAccountMenu.server.vue' 79 - import HeaderAccountMenuClient from '~/components/HeaderAccountMenu.client.vue' 80 - import ClaimPackageModal from '~/components/ClaimPackageModal.vue' 81 - import OperationsQueue from '~/components/OperationsQueue.vue' 82 - import PackageList from '~/components/PackageList.vue' 83 - import PackageMetricsBadges from '~/components/PackageMetricsBadges.vue' 84 - import PackageAccessControls from '~/components/PackageAccessControls.vue' 85 - import OrgMembersPanel from '~/components/OrgMembersPanel.vue' 86 - import OrgTeamsPanel from '~/components/OrgTeamsPanel.vue' 87 - import CodeMobileTreeDrawer from '~/components/CodeMobileTreeDrawer.vue' 88 - import ColumnPicker from '~/components/ColumnPicker.vue' 89 - import FilterChips from '~/components/FilterChips.vue' 90 - import FilterPanel from '~/components/FilterPanel.vue' 91 - import PackageListToolbar from '~/components/PackageListToolbar.vue' 92 - import PackageTable from '~/components/PackageTable.vue' 93 - import PackageTableRow from '~/components/PackageTableRow.vue' 94 - import PaginationControls from '~/components/PaginationControls.vue' 95 - import ViewModeToggle from '~/components/ViewModeToggle.vue' 96 - import PackageVulnerabilityTree from '~/components/PackageVulnerabilityTree.vue' 97 - import PackageDeprecatedTree from '~/components/PackageDeprecatedTree.vue' 98 - import DependencyPathPopup from '~/components/DependencyPathPopup.vue' 99 - import CompareFacetSelector from '~/components/compare/FacetSelector.vue' 100 - import ComparePackageSelector from '~/components/compare/PackageSelector.vue' 101 - import CompareFacetRow from '~/components/compare/FacetRow.vue' 102 - import CompareComparisonGrid from '~/components/compare/ComparisonGrid.vue' 103 - import PackageManagerSelect from '~/components/PackageManagerSelect.vue' 128 + import ToggleServer from '~/components/Toggle.server.vue' 104 129 105 130 describe('component accessibility audits', () => { 106 131 describe('DateTime', () => { ··· 748 773 }) 749 774 }) 750 775 751 - describe('HeaderAccountMenu.client', () => { 776 + describe('HeaderAccountMenu', () => { 752 777 it('should have no accessibility violations', async () => { 753 - const component = await mountSuspended(HeaderAccountMenuClient) 778 + const component = await mountSuspended(HeaderAccountMenu) 754 779 const results = await runAxe(component) 755 780 expect(results.violations).toEqual([]) 756 781 }) ··· 1409 1434 describe('PackageManagerSelect', () => { 1410 1435 it('should have no accessibility violations', async () => { 1411 1436 const component = await mountSuspended(PackageManagerSelect) 1437 + const results = await runAxe(component) 1438 + expect(results.violations).toEqual([]) 1439 + }) 1440 + }) 1441 + 1442 + describe('CompareFacetCard', () => { 1443 + it('should have no accessibility violations with numeric values', async () => { 1444 + const component = await mountSuspended(CompareFacetCard, { 1445 + props: { 1446 + label: 'Downloads', 1447 + description: 'Weekly download count', 1448 + values: [ 1449 + { raw: 1000, display: '1,000' }, 1450 + { raw: 2000, display: '2,000' }, 1451 + ], 1452 + headers: ['vue', 'react'], 1453 + }, 1454 + }) 1455 + const results = await runAxe(component) 1456 + expect(results.violations).toEqual([]) 1457 + }) 1458 + 1459 + it('should have no accessibility violations when loading', async () => { 1460 + const component = await mountSuspended(CompareFacetCard, { 1461 + props: { 1462 + label: 'Install Size', 1463 + values: [null, null], 1464 + headers: ['vue', 'react'], 1465 + facetLoading: true, 1466 + }, 1467 + }) 1468 + const results = await runAxe(component) 1469 + expect(results.violations).toEqual([]) 1470 + }) 1471 + }) 1472 + 1473 + describe('AccentColorPicker', () => { 1474 + it('should have no accessibility violations', async () => { 1475 + const component = await mountSuspended(AccentColorPicker) 1476 + const results = await runAxe(component) 1477 + expect(results.violations).toEqual([]) 1478 + }) 1479 + }) 1480 + 1481 + describe('AuthButton.server', () => { 1482 + it('should have no accessibility violations', async () => { 1483 + const component = await mountSuspended(AuthButtonServer) 1484 + const results = await runAxe(component) 1485 + expect(results.violations).toEqual([]) 1486 + }) 1487 + }) 1488 + 1489 + describe('BaseTooltip', () => { 1490 + it('should have no accessibility violations when hidden', async () => { 1491 + const component = await mountSuspended(BaseTooltip, { 1492 + props: { text: 'Tooltip text', isVisible: false }, 1493 + slots: { default: '<button>Trigger</button>' }, 1494 + }) 1495 + const results = await runAxe(component) 1496 + expect(results.violations).toEqual([]) 1497 + }) 1498 + 1499 + it('should have no accessibility violations when visible', async () => { 1500 + const component = await mountSuspended(BaseTooltip, { 1501 + props: { text: 'Tooltip text', isVisible: true }, 1502 + slots: { default: '<button>Trigger</button>' }, 1503 + }) 1504 + const results = await runAxe(component) 1505 + expect(results.violations).toEqual([]) 1506 + }) 1507 + }) 1508 + 1509 + describe('BuildEnvironment', () => { 1510 + it('should have no accessibility violations', async () => { 1511 + const component = await mountSuspended(BuildEnvironment) 1512 + const results = await runAxe(component) 1513 + expect(results.violations).toEqual([]) 1514 + }) 1515 + 1516 + it('should have no accessibility violations in footer mode', async () => { 1517 + const component = await mountSuspended(BuildEnvironment, { 1518 + props: { footer: true }, 1519 + }) 1520 + const results = await runAxe(component) 1521 + expect(results.violations).toEqual([]) 1522 + }) 1523 + }) 1524 + 1525 + describe('CollapsibleSection', () => { 1526 + it('should have no accessibility violations', async () => { 1527 + const component = await mountSuspended(CollapsibleSection, { 1528 + props: { title: 'Section Title', id: 'test-section' }, 1529 + slots: { default: '<p>Section content</p>' }, 1530 + }) 1531 + const results = await runAxe(component) 1532 + expect(results.violations).toEqual([]) 1533 + }) 1534 + 1535 + it('should have no accessibility violations with custom heading level', async () => { 1536 + const component = await mountSuspended(CollapsibleSection, { 1537 + props: { title: 'Section Title', id: 'test-section', headingLevel: 'h3' }, 1538 + slots: { default: '<p>Section content</p>' }, 1539 + }) 1540 + const results = await runAxe(component) 1541 + expect(results.violations).toEqual([]) 1542 + }) 1543 + 1544 + it('should have no accessibility violations when loading', async () => { 1545 + const component = await mountSuspended(CollapsibleSection, { 1546 + props: { title: 'Section Title', id: 'test-section', isLoading: true }, 1547 + slots: { default: '<p>Loading content...</p>' }, 1548 + }) 1549 + const results = await runAxe(component) 1550 + expect(results.violations).toEqual([]) 1551 + }) 1552 + }) 1553 + 1554 + describe('ExecuteCommandTerminal', () => { 1555 + it('should have no accessibility violations', async () => { 1556 + const component = await mountSuspended(ExecuteCommandTerminal, { 1557 + props: { packageName: 'create-vite' }, 1558 + }) 1559 + const results = await runAxe(component) 1560 + expect(results.violations).toEqual([]) 1561 + }) 1562 + 1563 + it('should have no accessibility violations for create package', async () => { 1564 + const component = await mountSuspended(ExecuteCommandTerminal, { 1565 + props: { packageName: 'create-vite', isCreatePackage: true }, 1566 + }) 1567 + const results = await runAxe(component) 1568 + expect(results.violations).toEqual([]) 1569 + }) 1570 + }) 1571 + 1572 + describe('InstallCommandTerminal', () => { 1573 + it('should have no accessibility violations', async () => { 1574 + const component = await mountSuspended(InstallCommandTerminal, { 1575 + props: { packageName: 'vue' }, 1576 + }) 1577 + const results = await runAxe(component) 1578 + expect(results.violations).toEqual([]) 1579 + }) 1580 + 1581 + it('should have no accessibility violations with version', async () => { 1582 + const component = await mountSuspended(InstallCommandTerminal, { 1583 + props: { packageName: 'vue', requestedVersion: '3.5.0' }, 1584 + }) 1585 + const results = await runAxe(component) 1586 + expect(results.violations).toEqual([]) 1587 + }) 1588 + 1589 + it('should have no accessibility violations with types package', async () => { 1590 + const component = await mountSuspended(InstallCommandTerminal, { 1591 + props: { packageName: 'lodash', typesPackageName: '@types/lodash' }, 1592 + }) 1593 + const results = await runAxe(component) 1594 + expect(results.violations).toEqual([]) 1595 + }) 1596 + 1597 + it('should have no accessibility violations with executable info', async () => { 1598 + const component = await mountSuspended(InstallCommandTerminal, { 1599 + props: { 1600 + packageName: 'eslint', 1601 + executableInfo: { hasExecutable: true, primaryCommand: 'eslint' }, 1602 + }, 1603 + }) 1604 + const results = await runAxe(component) 1605 + expect(results.violations).toEqual([]) 1606 + }) 1607 + }) 1608 + 1609 + describe('LicenseDisplay', () => { 1610 + it('should have no accessibility violations with simple license', async () => { 1611 + const component = await mountSuspended(LicenseDisplay, { 1612 + props: { license: 'MIT' }, 1613 + }) 1614 + const results = await runAxe(component) 1615 + expect(results.violations).toEqual([]) 1616 + }) 1617 + 1618 + it('should have no accessibility violations with compound license', async () => { 1619 + const component = await mountSuspended(LicenseDisplay, { 1620 + props: { license: 'MIT OR Apache-2.0' }, 1621 + }) 1622 + const results = await runAxe(component) 1623 + expect(results.violations).toEqual([]) 1624 + }) 1625 + }) 1626 + 1627 + describe('PackageInstallScripts', () => { 1628 + it('should have no accessibility violations', async () => { 1629 + const component = await mountSuspended(PackageInstallScripts, { 1630 + props: { 1631 + packageName: 'esbuild', 1632 + installScripts: { 1633 + scripts: ['postinstall'], 1634 + content: { postinstall: 'node install.js' }, 1635 + npxDependencies: {}, 1636 + }, 1637 + }, 1638 + }) 1639 + const results = await runAxe(component) 1640 + expect(results.violations).toEqual([]) 1641 + }) 1642 + 1643 + it('should have no accessibility violations with npx dependencies', async () => { 1644 + const component = await mountSuspended(PackageInstallScripts, { 1645 + props: { 1646 + packageName: 'husky', 1647 + installScripts: { 1648 + scripts: ['postinstall'], 1649 + content: { postinstall: 'husky install' }, 1650 + npxDependencies: { husky: '^9.0.0' }, 1651 + }, 1652 + }, 1653 + }) 1654 + const results = await runAxe(component) 1655 + expect(results.violations).toEqual([]) 1656 + }) 1657 + }) 1658 + 1659 + describe('PackageReplacement', () => { 1660 + it('should have no accessibility violations for native replacement', async () => { 1661 + const component = await mountSuspended(PackageReplacement, { 1662 + props: { 1663 + replacement: { 1664 + type: 'native', 1665 + moduleName: 'array-every', 1666 + nodeVersion: '0.10.0', 1667 + replacement: 'Array.prototype.every', 1668 + mdnPath: 'Global_Objects/Array/every', 1669 + category: 'native', 1670 + }, 1671 + }, 1672 + }) 1673 + const results = await runAxe(component) 1674 + expect(results.violations).toEqual([]) 1675 + }) 1676 + 1677 + it('should have no accessibility violations for simple replacement', async () => { 1678 + const component = await mountSuspended(PackageReplacement, { 1679 + props: { 1680 + replacement: { 1681 + type: 'simple', 1682 + moduleName: 'underscore', 1683 + replacement: 'lodash', 1684 + }, 1685 + }, 1686 + }) 1687 + const results = await runAxe(component) 1688 + expect(results.violations).toEqual([]) 1689 + }) 1690 + 1691 + it('should have no accessibility violations for documented replacement', async () => { 1692 + const component = await mountSuspended(PackageReplacement, { 1693 + props: { 1694 + replacement: { 1695 + type: 'documented', 1696 + moduleName: 'moment', 1697 + docPath: 'moment', 1698 + }, 1699 + }, 1700 + }) 1701 + const results = await runAxe(component) 1702 + expect(results.violations).toEqual([]) 1703 + }) 1704 + }) 1705 + 1706 + describe('PackageSkillsCard', () => { 1707 + it('should have no accessibility violations with skills', async () => { 1708 + const component = await mountSuspended(PackageSkillsCard, { 1709 + props: { 1710 + packageName: 'vue', 1711 + skills: [ 1712 + { 1713 + name: 'Vue Components', 1714 + description: 'Create Vue components', 1715 + dirName: 'vue-components', 1716 + }, 1717 + ], 1718 + }, 1719 + }) 1720 + const results = await runAxe(component) 1721 + expect(results.violations).toEqual([]) 1722 + }) 1723 + 1724 + it('should render nothing when no skills', async () => { 1725 + const component = await mountSuspended(PackageSkillsCard, { 1726 + props: { 1727 + packageName: 'vue', 1728 + skills: [], 1729 + }, 1730 + }) 1731 + // Empty skills array means the component renders nothing 1732 + expect(component.html()).toBe('<!--v-if-->') 1733 + }) 1734 + }) 1735 + 1736 + describe('Readme', () => { 1737 + it('should have no accessibility violations with slot content', async () => { 1738 + const component = await mountSuspended(Readme, { 1739 + slots: { default: '<h3>README</h3><p>Some content</p>' }, 1740 + }) 1741 + const results = await runAxe(component) 1742 + expect(results.violations).toEqual([]) 1743 + }) 1744 + }) 1745 + 1746 + describe('SearchBox', () => { 1747 + it('should have no accessibility violations', async () => { 1748 + const component = await mountSuspended(SearchBox) 1749 + const results = await runAxe(component) 1750 + expect(results.violations).toEqual([]) 1751 + }) 1752 + }) 1753 + 1754 + describe('SearchSuggestionCard', () => { 1755 + it('should have no accessibility violations for user suggestion', async () => { 1756 + const component = await mountSuspended(SearchSuggestionCard, { 1757 + props: { type: 'user', name: 'testuser' }, 1758 + }) 1759 + const results = await runAxe(component) 1760 + expect(results.violations).toEqual([]) 1761 + }) 1762 + 1763 + it('should have no accessibility violations for org suggestion', async () => { 1764 + const component = await mountSuspended(SearchSuggestionCard, { 1765 + props: { type: 'org', name: 'testorg' }, 1766 + }) 1767 + const results = await runAxe(component) 1768 + expect(results.violations).toEqual([]) 1769 + }) 1770 + 1771 + it('should have no accessibility violations for exact match', async () => { 1772 + const component = await mountSuspended(SearchSuggestionCard, { 1773 + props: { type: 'user', name: 'exactuser', isExactMatch: true }, 1774 + }) 1775 + const results = await runAxe(component) 1776 + expect(results.violations).toEqual([]) 1777 + }) 1778 + }) 1779 + 1780 + describe('Toggle.server', () => { 1781 + it('should have no accessibility violations', async () => { 1782 + const component = await mountSuspended(ToggleServer, { 1783 + props: { label: 'Enable feature' }, 1784 + }) 1785 + const results = await runAxe(component) 1786 + expect(results.violations).toEqual([]) 1787 + }) 1788 + 1789 + it('should have no accessibility violations with description', async () => { 1790 + const component = await mountSuspended(ToggleServer, { 1791 + props: { label: 'Enable feature', description: 'This enables the feature' }, 1792 + }) 1793 + const results = await runAxe(component) 1794 + expect(results.violations).toEqual([]) 1795 + }) 1796 + }) 1797 + 1798 + describe('Toggle', () => { 1799 + it('should have no accessibility violations', async () => { 1800 + const component = await mountSuspended(Toggle, { 1801 + props: { label: 'Enable feature' }, 1802 + }) 1803 + const results = await runAxe(component) 1804 + expect(results.violations).toEqual([]) 1805 + }) 1806 + 1807 + it('should have no accessibility violations with description', async () => { 1808 + const component = await mountSuspended(Toggle, { 1809 + props: { label: 'Enable feature', description: 'This enables the feature' }, 1810 + }) 1811 + const results = await runAxe(component) 1812 + expect(results.violations).toEqual([]) 1813 + }) 1814 + 1815 + it('should have no accessibility violations when checked', async () => { 1816 + const component = await mountSuspended(Toggle, { 1817 + props: { label: 'Enable feature', modelValue: true }, 1818 + }) 1819 + const results = await runAxe(component) 1820 + expect(results.violations).toEqual([]) 1821 + }) 1822 + }) 1823 + 1824 + describe('VersionSelector', () => { 1825 + const mockVersions = { 1826 + '3.5.0': {}, 1827 + '3.4.0': {}, 1828 + '3.3.0': {}, 1829 + } 1830 + const mockDistTags = { 1831 + latest: '3.5.0', 1832 + next: '3.4.0', 1833 + } 1834 + 1835 + it('should have no accessibility violations', async () => { 1836 + const component = await mountSuspended(VersionSelector, { 1837 + props: { 1838 + packageName: 'vue', 1839 + currentVersion: '3.5.0', 1840 + versions: mockVersions, 1841 + distTags: mockDistTags, 1842 + urlPattern: '/vue/v/{version}', 1843 + }, 1844 + }) 1845 + const results = await runAxe(component) 1846 + expect(results.violations).toEqual([]) 1847 + }) 1848 + 1849 + it('should have no accessibility violations with non-latest version', async () => { 1850 + const component = await mountSuspended(VersionSelector, { 1851 + props: { 1852 + packageName: 'vue', 1853 + currentVersion: '3.4.0', 1854 + versions: mockVersions, 1855 + distTags: mockDistTags, 1856 + urlPattern: '/vue/v/{version}', 1857 + }, 1858 + }) 1412 1859 const results = await runAxe(component) 1413 1860 expect(results.violations).toEqual([]) 1414 1861 })
+197
test/unit/a11y-component-coverage.spec.ts
··· 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 + */ 11 + import fs from 'node:fs' 12 + import path from 'node:path' 13 + import { assert, describe, it } from 'vitest' 14 + import { 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 + */ 24 + const 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 + 'AuthButton.client.vue': 31 + 'Client component with AuthModal dependency - AuthButton.server.vue tested', 32 + 'AuthModal.client.vue': 'Complex auth modal with navigation - requires full app context', 33 + 34 + // Complex components requiring full app context or specific runtime conditions 35 + 'HeaderOrgsDropdown.vue': 'Requires connector context and API calls', 36 + 'HeaderPackagesDropdown.vue': 'Requires connector context and API calls', 37 + 'MobileMenu.vue': 'Requires Teleport and full navigation context', 38 + 'Modal.client.vue': 39 + 'Base modal component - tested via specific modals like ChartModal, ConnectorModal', 40 + 'PackageSkillsModal.vue': 'Complex modal with tabs - requires modal context and state', 41 + 'ScrollToTop.vue': 'Requires scroll position and CSS scroll-state queries', 42 + 'TranslationHelper.vue': 'i18n helper component - requires specific locale status data', 43 + 'PackageWeeklyDownloadStats.vue': 44 + 'Uses vue-data-ui VueUiSparkline - has DOM measurement issues in test environment', 45 + } 46 + 47 + /** 48 + * Recursively get all Vue component files in a directory. 49 + */ 50 + function getVueFiles(dir: string, baseDir: string = dir): string[] { 51 + const files: string[] = [] 52 + const entries = fs.readdirSync(dir, { withFileTypes: true }) 53 + 54 + for (const entry of entries) { 55 + const fullPath = path.join(dir, entry.name) 56 + if (entry.isDirectory()) { 57 + files.push(...getVueFiles(fullPath, baseDir)) 58 + } else if (entry.isFile() && entry.name.endsWith('.vue')) { 59 + // Get relative path from base components directory 60 + files.push(path.relative(baseDir, fullPath)) 61 + } 62 + } 63 + 64 + return files 65 + } 66 + 67 + /** 68 + * Extract tested component names from the test file. 69 + * Handles both #components imports and direct ~/components/ imports. 70 + */ 71 + function getTestedComponents(testFileContent: string): Set<string> { 72 + const tested = new Set<string>() 73 + 74 + // Match direct imports like: 75 + // import ComponentName from '~/components/ComponentName.vue' 76 + // import ComponentName from '~/components/subdir/ComponentName.vue' 77 + const directImportRegex = /import\s+\w+\s+from\s+['"]~\/components\/(.+\.vue)['"]/g 78 + let match 79 + 80 + while ((match = directImportRegex.exec(testFileContent)) !== null) { 81 + tested.add(match[1]!) 82 + } 83 + 84 + // Match #components imports like: 85 + // import { ComponentName, OtherComponent } from '#components' 86 + const hashComponentsRegex = /import\s*\{([^}]+)\}\s*from\s*['"]#components['"]/g 87 + while ((match = hashComponentsRegex.exec(testFileContent)) !== null) { 88 + const importList = match[1]! 89 + // Parse the import list, handling multi-line imports 90 + const componentNames = importList 91 + .split(',') 92 + .map(name => name.trim()) 93 + .filter(name => name.length > 0) 94 + 95 + for (const name of componentNames) { 96 + // Map #components name to file path(s) 97 + const filePaths = mapComponentNameToFiles(name) 98 + for (const filePath of filePaths) { 99 + tested.add(filePath) 100 + } 101 + } 102 + } 103 + 104 + return tested 105 + } 106 + 107 + /** 108 + * Map a #components export name to the actual file path(s). 109 + * Handles various naming conventions. 110 + * 111 + * Returns an array because importing from #components can cover multiple files: 112 + * - `HeaderAccountMenu` from #components -> tests HeaderAccountMenu.client.vue 113 + * (Nuxt auto-resolves to client variant when both .server and .client exist) 114 + */ 115 + function mapComponentNameToFiles(name: string): string[] { 116 + // Handle Compare* prefix -> compare/ subdirectory 117 + if (name.startsWith('Compare')) { 118 + const baseName = name.slice('Compare'.length) 119 + return [`compare/${baseName}.vue`] 120 + } 121 + 122 + // Regular component - could be .vue or .client.vue 123 + // When importing from #components, Nuxt resolves to the client variant if it exists 124 + return [`${name}.vue`, `${name}.client.vue`] 125 + } 126 + 127 + describe('a11y component test coverage', () => { 128 + const componentsDir = fileURLToPath(new URL('../../app/components', import.meta.url)) 129 + const testFilePath = fileURLToPath(new URL('../nuxt/a11y.spec.ts', import.meta.url)) 130 + 131 + it('should have accessibility tests for all components (or be explicitly skipped)', () => { 132 + // Get all Vue components 133 + const allComponents = getVueFiles(componentsDir) 134 + 135 + // Get components that are tested 136 + const testFileContent = fs.readFileSync(testFilePath, 'utf-8') 137 + const testedComponents = getTestedComponents(testFileContent) 138 + 139 + // Find components that are neither tested nor skipped 140 + const missingTests = allComponents.filter( 141 + component => !testedComponents.has(component) && !SKIPPED_COMPONENTS[component], 142 + ) 143 + 144 + // Fail with helpful message if any components are missing tests 145 + assert.strictEqual(missingTests.length, 0, buildMissingTestsMessage(missingTests)) 146 + }) 147 + 148 + it('should not have obsolete entries in SKIPPED_COMPONENTS', () => { 149 + const allComponents = getVueFiles(componentsDir) 150 + const componentSet = new Set(allComponents) 151 + 152 + const obsoleteSkips = Object.keys(SKIPPED_COMPONENTS).filter( 153 + component => !componentSet.has(component), 154 + ) 155 + 156 + assert.strictEqual(obsoleteSkips.length, 0, buildObsoleteSkipsMessage(obsoleteSkips)) 157 + }) 158 + 159 + it('should not skip components that are actually tested', () => { 160 + const testFileContent = fs.readFileSync(testFilePath, 'utf-8') 161 + const testedComponents = getTestedComponents(testFileContent) 162 + 163 + const unnecessarySkips = Object.keys(SKIPPED_COMPONENTS).filter(component => 164 + testedComponents.has(component), 165 + ) 166 + 167 + assert.strictEqual(unnecessarySkips.length, 0, buildUnnecessarySkipsMessage(unnecessarySkips)) 168 + }) 169 + }) 170 + 171 + function buildMissingTestsMessage(missingTests: string[]): string { 172 + if (missingTests.length === 0) return '' 173 + return ( 174 + `Missing a11y tests for ${missingTests.length} component(s):\n` + 175 + missingTests.map(c => ` - ${c}`).join('\n') + 176 + '\n\nTo fix: Add tests in test/nuxt/a11y.spec.ts or add to SKIPPED_COMPONENTS ' + 177 + 'in test/unit/a11y-component-coverage.spec.ts with justification.' 178 + ) 179 + } 180 + 181 + function buildObsoleteSkipsMessage(obsoleteSkips: string[]): string { 182 + if (obsoleteSkips.length === 0) return '' 183 + return ( 184 + `Obsolete SKIPPED_COMPONENTS entries:\n` + 185 + obsoleteSkips.map(c => ` - ${c}`).join('\n') + 186 + '\n\nThese components no longer exist. Remove them from SKIPPED_COMPONENTS.' 187 + ) 188 + } 189 + 190 + function buildUnnecessarySkipsMessage(unnecessarySkips: string[]): string { 191 + if (unnecessarySkips.length === 0) return '' 192 + return ( 193 + `Unnecessary SKIPPED_COMPONENTS entries:\n` + 194 + unnecessarySkips.map(c => ` - ${c}`).join('\n') + 195 + '\n\nThese components have tests now. Remove them from SKIPPED_COMPONENTS.' 196 + ) 197 + }