From ff968c0f6a4a820b6f191f1847f0b1ff30034862 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 16 Oct 2026 10:52:32 +0100 Subject: [PATCH] ci: gate coverage, test on Node 26, and run the pack test on Node 20.19 Coverage of src/ and action/ was neither measured nor enforced. pnpm test:coverage now measures it with @vitest/coverage-istanbul, with thresholds at today's level rounded down (lines 94, branches 84, functions 93, statements 91), in one CI job on Node 24 that also writes the totals to the job summary. pnpm test and the other test jobs run without it. Istanbul rather than v8: 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, past the 30 s test timeout. The test matrix adds Node 26, which becomes LTS on 2026-10-28. The Node 20.19 job, which ran only the smoke test and one check, now also runs both pack test scripts there, with node, since pnpm needs a newer Node. --- .github/workflows/ci.yml | 46 ++++++- package.json | 2 + pnpm-lock.yaml | 252 ++++++++++++++++++++++++++++++++++++++- test/ci-gates.test.ts | 196 ++++++++++++++++++++++++++++++ vitest.config.ts | 12 ++ 5 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 test/ci-gates.test.ts 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 }, + }, }, });