diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0655213a..ed3fe063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,7 @@ jobs: grep -q '^::error file=examples/shadcn-demo/app/settings/members.tsx,line=45,col=28,.*title=onsystem%3A no-unknown-variant::"danger" is not a valid variant for ' action.out node20: - name: Built CLI on Node 20.19 + name: Built CLI and pack test on Node 20.19 runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -128,6 +128,13 @@ jobs: - name: Check the demo's clean page working-directory: examples/shadcn-demo run: node ../../dist/cli.js check app/settings/members.tsx + # pnpm pack-test, one script at a time, since pnpm itself needs a newer Node: npm pack, npm + # install into a new project, the installed bin, MCP in both protocol eras, the hook and the + # ESLint plugin, then the package in a project on ESLint 8.57.0. + - name: Pack, install and run the package + run: node scripts/pack-test.mjs + - name: Pack, install and run the package with ESLint 8.57.0 + run: node scripts/pack-test-eslint8.mjs site: name: Website @@ -156,7 +163,7 @@ jobs: strategy: fail-fast: false matrix: - node: [22, 24] + node: [22, 24, 26] steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 @@ -167,6 +174,41 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm test + coverage: + # The line, branch, function and statement coverage of src/ and action/, which mustn't fall + # below the thresholds in vitest.config.ts. Measuring it slows the suite down, so this is the + # one job that does. + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:coverage + - name: Write the coverage to the job summary + if: ${{ !cancelled() }} + run: | + node --input-type=module - >> "$GITHUB_STEP_SUMMARY" <<'EOF' + import fs from 'node:fs'; + const file = 'coverage/coverage-summary.json'; + if (!fs.existsSync(file)) { + console.log('No coverage report: the tests failed before Vitest wrote one.'); + process.exit(0); + } + const { total } = JSON.parse(fs.readFileSync(file, 'utf8')); + console.log('### Coverage of src/ and action/\n'); + console.log('| Metric | Coverage | Covered |'); + console.log('| --- | --: | --: |'); + for (const metric of ['Lines', 'Branches', 'Functions', 'Statements']) { + const { pct, covered, total: all } = total[metric.toLowerCase()]; + console.log(`| ${metric} | ${pct}% | ${covered} of ${all} |`); + } + EOF + os: name: Test on ${{ matrix.os }} runs-on: ${{ matrix.os }} diff --git a/package.json b/package.json index f95809a1..1bc649a4 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "smoke": "node scripts/smoke.mjs", "pack-test": "node scripts/pack-test.mjs && node scripts/pack-test-eslint8.mjs", "assets": "node scripts/build-assets.mjs", @@ -111,6 +112,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@types/culori": "^4.0.1", "@types/node": "^24.19.2", + "@vitest/coverage-istanbul": "^5.0.3", "ajv": "^8.20.0", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48f84921..99802247 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@types/node': specifier: ^24.19.2 version: 24.19.2 + '@vitest/coverage-istanbul': + specifier: ^5.0.3 + version: 5.0.3(vitest@5.0.3) ajv: specifier: ^8.20.0 version: 8.20.0 @@ -101,7 +104,7 @@ importers: version: 8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: ^5.0.3 - version: 5.0.3(@types/node@24.19.2)(vite@8.3.2(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@24.19.2)(@vitest/coverage-istanbul@5.0.3)(vite@8.3.2(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1)) examples/shadcn-demo: dependencies: @@ -229,7 +232,7 @@ importers: version: 8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: ^5.0.3 - version: 5.0.3(@types/node@24.19.2)(vite@8.3.2(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@24.19.2)(@vitest/coverage-istanbul@5.0.3)(vite@8.3.2(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1)) packages: @@ -263,26 +266,50 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@8.0.6': + resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/compat-data@7.29.7': resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} + '@babel/compat-data@8.0.5': + resolution: {integrity: sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/core@7.29.7': resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} + '@babel/core@8.0.7': + resolution: {integrity: sha512-ZIsZWBG6Y1oa+8fkQVp0xeCdjCHBkXFCCIWrmg9SvejVvfcVQgKKRmGrBDWfM7rilhvnRoEuaqa5dkK7KB3pHA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/generator@7.29.8': resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.6': + resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-compilation-targets@7.29.7': resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@8.0.7': + resolution: {integrity: sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@8.0.6': + resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} @@ -297,35 +324,68 @@ packages: resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.6': + resolution: {integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.6': + resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@8.0.0': + resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helpers@7.29.10': resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} engines: {node: '>=6.9.0'} + '@babel/helpers@8.0.7': + resolution: {integrity: sha512-5dMj5kFwS2AY7Dt4PZp4px3c5ThMbbEa7tZfuHTncCGSFYq8uoA1ufDE1IS/lYpQedE+3+GQ3cPZUiplIu195w==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/parser@7.29.9': resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@8.0.7': + resolution: {integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/traverse@7.29.10': resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@8.0.7': + resolution: {integrity: sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.6': + resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} + engines: {node: ^22.18.0 || >=24.11.0} + '@cacheable/memory@2.2.0': resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} @@ -682,9 +742,16 @@ packages: '@internationalized/string@3.2.10': resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.4.0-beta.0': + resolution: {integrity: sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==} + '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -1235,9 +1302,15 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/gensync@1.0.5': + resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1353,6 +1426,27 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vitest/coverage-istanbul@5.0.3': + resolution: {integrity: sha512-ffvonoWX3hcHTBEBjbXcbLgr2nj4ZpBhK3t05HlIcIuZjd4iwVsswAYpB8TTAcqPTsofeTj2Ywva1STUUjpgRw==} + peerDependencies: + vitest: 5.0.3 + + '@vitest/istanbul-lib-coverage@1.0.2': + resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-instrument@1.0.2': + resolution: {integrity: sha512-KFlvK0uYxYjdNoQJR+6F1B45u0ZOmYgOZndqfwLiGBjYHACc8q1Wyuw/ZQeGP3UKc7XsSZ51NKQDKt0IIbGMQg==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.2': + resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-source-maps@1.0.2': + resolution: {integrity: sha512-Ia3+9WDB5UKN940ztJ5LyhqDaS/It05Uh2VQ70KIPDWlOed2ZsJTJTyMb7x6OIW4eAwWSBFknpqJafJetTTXLw==} + engines: {node: '>=22'} + '@vitest/mocker@5.0.3': resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} peerDependencies: @@ -2006,6 +2100,10 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + flru@1.0.2: + resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} + engines: {node: '>=6'} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2324,6 +2422,9 @@ packages: jose@6.2.12: resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2559,6 +2660,9 @@ packages: magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3168,6 +3272,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3544,8 +3652,15 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@8.0.6': + dependencies: + '@babel/helper-validator-identifier': 8.0.6 + js-tokens: 10.0.0 + '@babel/compat-data@7.29.7': {} + '@babel/compat-data@8.0.5': {} + '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -3566,6 +3681,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@8.0.7': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-compilation-targets': 8.0.7 + '@babel/helpers': 8.0.7 + '@babel/parser': 8.0.7 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 + '@types/gensync': 1.0.5 + convert-source-map: 2.0.0 + empathic: 2.1.0 + gensync: 1.0.0-beta.2 + import-meta-resolve: 4.2.0 + json5: 2.2.3 + obug: 3.0.0 + verkit: 0.5.0 + '@babel/generator@7.29.8': dependencies: '@babel/parser': 7.29.9 @@ -3574,6 +3708,15 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@8.0.6': + dependencies: + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 + '@jridgewell/gen-mapping': 0.4.0-beta.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/helper-compilation-targets@7.29.7': dependencies: '@babel/compat-data': 7.29.7 @@ -3582,8 +3725,18 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@8.0.7': + dependencies: + '@babel/compat-data': 8.0.5 + '@babel/helper-validator-option': 8.0.0 + browserslist: 4.29.3 + flru: 1.0.2 + verkit: 0.5.0 + '@babel/helper-globals@7.29.7': {} + '@babel/helper-globals@8.0.6': {} + '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.10 @@ -3602,25 +3755,46 @@ snapshots: '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.6': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.6': {} + '@babel/helper-validator-option@7.29.7': {} + '@babel/helper-validator-option@8.0.0': {} + '@babel/helpers@7.29.10': dependencies: '@babel/template': 7.29.7 '@babel/types': 7.29.8 + '@babel/helpers@8.0.7': + dependencies: + '@babel/template': 8.0.0 + '@babel/types': 8.0.6 + '@babel/parser@7.29.9': dependencies: '@babel/types': 7.29.8 + '@babel/parser@8.0.7': + dependencies: + '@babel/types': 8.0.6 + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.9 '@babel/types': 7.29.8 + '@babel/template@8.0.0': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 + '@babel/traverse@7.29.10': dependencies: '@babel/code-frame': 7.29.7 @@ -3633,11 +3807,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@8.0.7': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-globals': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/template': 8.0.0 + '@babel/types': 8.0.6 + obug: 3.0.0 + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.6': + dependencies: + '@babel/helper-string-parser': 8.0.6 + '@babel/helper-validator-identifier': 8.0.6 + '@cacheable/memory@2.2.0': dependencies: '@cacheable/utils': 2.5.0 @@ -3997,11 +4186,18 @@ snapshots: dependencies: '@swc/helpers': 0.5.23 + '@istanbuljs/schema@0.1.6': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.4.0-beta.0': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -4453,10 +4649,14 @@ snapshots: '@types/estree@1.0.9': {} + '@types/gensync@1.0.5': {} + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 + '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -4577,6 +4777,39 @@ snapshots: '@vercel/oidc@3.2.0': {} + '@vitest/coverage-istanbul@5.0.3(vitest@5.0.3)': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-instrument': 1.0.2 + '@vitest/istanbul-lib-report': 1.0.2 + '@vitest/istanbul-lib-source-maps': 1.0.2 + magicast: 0.5.5 + obug: 2.2.1 + tinyrainbow: 3.2.0 + vitest: 5.0.3(@types/node@24.19.2)(@vitest/coverage-istanbul@5.0.3)(vite@8.3.2(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1)) + + '@vitest/istanbul-lib-coverage@1.0.2': {} + + '@vitest/istanbul-lib-instrument@1.0.2': + dependencies: + '@babel/core': 8.0.7 + '@babel/parser': 8.0.7 + '@istanbuljs/schema': 0.1.6 + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/istanbul-lib-coverage': 1.0.2 + + '@vitest/istanbul-lib-report@1.0.2': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.2 + + '@vitest/istanbul-lib-source-maps@1.0.2': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/istanbul-lib-coverage': 1.0.2 + obug: 2.2.1 + '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -5341,6 +5574,8 @@ snapshots: flatted@3.4.4: {} + flru@1.0.2: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -5650,6 +5885,8 @@ snapshots: jose@6.2.12: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.3.2: @@ -5827,6 +6064,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 + math-intrinsics@1.1.0: {} mdast-util-to-hast@13.2.1: @@ -6492,6 +6735,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyrainbow@3.2.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6688,7 +6933,7 @@ snapshots: jiti: 2.7.0 yaml: 2.9.1 - vitest@5.0.3(@types/node@24.19.2)(vite@8.3.2(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1)): + vitest@5.0.3(@types/node@24.19.2)(@vitest/coverage-istanbul@5.0.3)(vite@8.3.2(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1)) @@ -6706,6 +6951,7 @@ snapshots: why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 24.19.2 + '@vitest/coverage-istanbul': 5.0.3(vitest@5.0.3) transitivePeerDependencies: - msw diff --git a/test/ci-gates.test.ts b/test/ci-gates.test.ts new file mode 100644 index 00000000..8b35ac18 --- /dev/null +++ b/test/ci-gates.test.ts @@ -0,0 +1,196 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; + +import config from '../vitest.config.js'; + +// What CI holds a change to besides passing tests: the coverage of src/ and action/ can't drop +// below the thresholds in vitest.config.ts (one job measures it, on Node 24, and writes it to the +// job summary; the other test jobs run without it, at full speed), the tests run on Node 22, 24 +// and 26, and the package is packed, installed and run on Node 20.19, the oldest Node that +// package.json admits. + +interface Step { + name?: string; + if?: string; + uses?: string; + run?: string; + with?: Record; +} + +interface Job { + name?: string; + 'runs-on'?: string; + strategy?: { matrix?: Record }; + steps: Step[]; +} + +const root = path.resolve(import.meta.dirname, '..'); +const jobs = ( + parse(fs.readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8')) as { + jobs: Record; + } +).jobs; +const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { + engines: { node: string }; + scripts: Record; + devDependencies: Record; +}; +const coverage = config.test?.coverage; + +/** The Node version a job's setup-node step (the first, or the one at `index`) installs. */ +function nodeVersion(job: Job | undefined, index = 0): string | undefined { + const setup = job?.steps.filter((step) => step.uses?.startsWith('actions/setup-node@'))[index]; + const version = setup?.with?.['node-version']; + return version === undefined ? undefined : String(version); +} + +/** The jobs that run the test suite, by the command they run it with. */ +function testRuns(): { job: string; run: string }[] { + return Object.entries(jobs).flatMap(([job, value]) => + (value?.steps ?? []) + .map((step) => step.run?.trim() ?? '') + .filter((run) => /^pnpm (test|vitest)\b/.test(run)) + .map((run) => ({ job, run })), + ); +} + +describe('coverage', () => { + it('is measured with Istanbul over src/ and action/, only when asked for', () => { + expect(coverage?.provider).toBe('istanbul'); + expect(coverage?.include).toEqual(expect.arrayContaining(['src/**', 'action/**'])); + // `pnpm test` stays as fast as it was: coverage runs with `pnpm test:coverage`. + expect(coverage?.enabled).not.toBe(true); + expect(pkg.scripts.test).toBe('vitest run'); + expect(pkg.scripts['test:coverage']).toBe('vitest run --coverage'); + // The json-summary report is what CI writes to the job summary. + expect(coverage?.reporter).toEqual(expect.arrayContaining(['json-summary'])); + }); + + it('has a floor for lines, branches, functions and statements', () => { + const thresholds = coverage?.thresholds; + for (const metric of ['lines', 'branches', 'functions', 'statements'] as const) { + expect(thresholds?.[metric], metric).toBeGreaterThan(0); + expect(thresholds?.[metric], metric).toBeLessThanOrEqual(100); + } + }); + + it('comes from the provider that matches the installed Vitest', () => { + // Vitest refuses to mix versions with its coverage provider: they move together. + expect(pkg.devDependencies['@vitest/coverage-istanbul']).toBe(pkg.devDependencies.vitest); + const installed = (name: string) => + ( + JSON.parse( + fs.readFileSync(path.join(root, 'node_modules', name, 'package.json'), 'utf8'), + ) as { version: string } + ).version; + expect(installed('@vitest/coverage-istanbul')).toBe(installed('vitest')); + }); + + it('runs in one CI job, on Node 24 and Ubuntu, and every other test job runs without it', () => { + expect(testRuns().filter(({ run }) => run.includes('coverage'))).toEqual([ + { job: 'coverage', run: 'pnpm test:coverage' }, + ]); + expect(jobs.coverage?.['runs-on']).toBe('ubuntu-latest'); + expect(nodeVersion(jobs.coverage)).toBe('24'); + for (const job of ['test', 'os']) { + expect(testRuns().filter((run) => run.job === job)).toEqual([{ job, run: 'pnpm test' }]); + } + }); + + const summary = jobs.coverage?.steps.find((step) => step.run?.includes('GITHUB_STEP_SUMMARY')); + const hasBash = + process.platform !== 'win32' && spawnSync('sh', ['-c', 'command -v bash']).status === 0; + + /** Runs the summary step in bash, in a new folder holding `report` as the json-summary. */ + function writeSummary(report?: unknown) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-step-')); + try { + if (report !== undefined) { + fs.mkdirSync(path.join(dir, 'coverage')); + fs.writeFileSync(path.join(dir, 'coverage/coverage-summary.json'), JSON.stringify(report)); + } + fs.writeFileSync(path.join(dir, 'step.sh'), summary?.run ?? 'exit 1'); + const file = path.join(dir, 'step-summary.md'); + const result = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', 'step.sh'], { + cwd: dir, + env: { + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`, + GITHUB_STEP_SUMMARY: file, + }, + encoding: 'utf8', + }); + const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; + return { status: result.status, stderr: result.stderr, text }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + it('writes the totals to the job summary, also when a threshold fails', () => { + expect(summary?.if).toBe('${{ !cancelled() }}'); + const steps = jobs.coverage?.steps ?? []; + const measure = steps.findIndex((step) => step.run === 'pnpm test:coverage'); + expect(measure).toBeGreaterThanOrEqual(0); + expect(steps.indexOf(summary ?? {})).toBeGreaterThan(measure); + }); + + it.skipIf(!hasBash)('lists each metric with what is covered of the total', () => { + const metric = (covered: number, total: number) => ({ + total, + covered, + skipped: 0, + pct: Math.round((covered / total) * 10_000) / 100, + }); + const { status, stderr, text } = writeSummary({ + total: { + lines: metric(9342, 10_000), + statements: metric(909, 1000), + functions: metric(2, 3), + branches: metric(7389, 8655), + }, + 'src/index.ts': { lines: metric(1, 1) }, + }); + expect(stderr).toBe(''); + expect(status).toBe(0); + expect(text).toContain('| Lines | 93.42% | 9342 of 10000 |'); + expect(text).toContain('| Statements | 90.9% | 909 of 1000 |'); + expect(text).toContain('| Functions | 66.67% | 2 of 3 |'); + expect(text).toContain('| Branches | 85.37% | 7389 of 8655 |'); + expect(text).not.toContain('src/index.ts'); + }); + + it.skipIf(!hasBash)('says there is no report when the tests failed before writing one', () => { + const { status, text } = writeSummary(); + expect(status).toBe(0); + expect(text).toMatch(/no coverage report/i); + }); +}); + +describe('Node versions', () => { + it('runs the tests on Node 22, 24 and 26', () => { + expect(jobs.test?.strategy?.matrix?.node).toEqual([22, 24, 26]); + }); + + it('packs, installs and runs the package on Node 20.19, the oldest version it admits', () => { + expect(pkg.engines.node).toBe('>=20.19'); + const job = jobs.node20; + const steps = job?.steps ?? []; + expect(nodeVersion(job, 1)).toBe('20.19'); + const switched = steps.findIndex((step) => step.with?.['node-version'] === '20.19'); + const before = steps.slice(0, switched).map((step) => step.run); + const after = steps.slice(switched + 1).map((step) => step.run ?? ''); + // Built on Node 24, since the build tools need it; only what the package ships runs on 20.19. + expect(before).toContain('pnpm build'); + // pnpm itself needs a newer Node, so the pack test's scripts run with node, as `pnpm + // pack-test` runs them. + expect(after.filter((run) => /^pnpm\b/m.test(run))).toEqual([]); + const packTest = pkg.scripts['pack-test']?.split(' && ') ?? []; + expect(packTest).toEqual(['node scripts/pack-test.mjs', 'node scripts/pack-test-eslint8.mjs']); + expect(after.filter((run) => packTest.includes(run))).toEqual(packTest); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1b6d78c5..1d6ce6b9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,5 +6,17 @@ export default defineConfig({ // Building a TypeScript program for the fixtures takes a moment on cold CI runners. testTimeout: 30_000, hookTimeout: 60_000, + // Measured by pnpm test:coverage, not pnpm test, since it slows the suite down: CI measures it + // in one job. Istanbul instruments only the files below. v8 counts every block V8 runs, the + // TypeScript compiler's included, and made the tests that build a program about six times + // slower on CI. + coverage: { + provider: 'istanbul', + include: ['src/**', 'action/**'], + reporter: ['text', 'json-summary'], + // What the suite covers now, rounded down, so a change can't lower it. Raise them when it + // goes up. + thresholds: { lines: 94, branches: 84, functions: 93, statements: 91 }, + }, }, });