diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a982d7c055..a10910a688 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,3 +134,68 @@ jobs: --- ๐Ÿ”„ Updated automatically on each push to this PR + + # Coverage Matrix (non-blocking) + # Runs the full pipeline: analyze exports, collect V8 unit coverage, + # merge into final matrix, and build the Svelte dashboard. + # Zero manual path maintenance โ€” discovers packages and exports automatically. + - name: Generate coverage matrix and build dashboard + continue-on-error: true + run: NX_CLOUD_DISTRIBUTED_EXECUTION=false pnpm nx run @forgerock/coverage-matrix:coverage-report + + - name: Generate coverage matrix summary + continue-on-error: true + id: coverage-matrix-summary + run: | + node -e " + const fs = require('fs'); + try { + const data = JSON.parse(fs.readFileSync('tools/coverage-matrix/data/coverage-matrix.json', 'utf-8')); + const rows = data.packages.map(p => { + const s = p.summary; + const uPct = s.totalExports > 0 ? Math.round(s.unitCovered / s.totalExports * 100) : 0; + const ePct = s.totalExports > 0 ? Math.round(s.e2eCovered / s.totalExports * 100) : 0; + const fPct = s.totalSourceFiles > 0 ? Math.round(s.unitTestedFiles / s.totalSourceFiles * 100) : 0; + return '| ' + p.name + ' | ' + s.totalExports + ' | ' + fPct + '% (' + s.unitTestedFiles + '/' + s.totalSourceFiles + ') | ' + uPct + '% | ' + ePct + '% | ' + s.uncovered + ' |'; + }); + const table = '| Package | Exports | Unit Files | Unit Exports | E2E | Uncovered |\n|---------|---------|------------|--------------|-----|-----------|' + '\n' + rows.join('\n'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, 'table< + + - name: Post coverage matrix PR comment + continue-on-error: true + if: github.event_name == 'pull_request' + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-coverage-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + edit-mode: replace + body: | + + ## Coverage Matrix + + ${{ steps.coverage-matrix-summary.outputs.table }} + + [Full dashboard](https://forgerock.github.io/ping-javascript-sdk/coverage-matrix/) ยท [PR preview](https://ForgeRock.github.io/ping-javascript-sdk/pr-${{ github.event.number }}/${{ github.sha }}/coverage-matrix/) + + - name: Preview coverage dashboard + continue-on-error: true + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./tools/coverage-matrix/dist + destination_dir: pr-${{ github.event.number }}/${{ github.sha }}/coverage-matrix + keep_files: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c752459b90..f715d495e6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -79,6 +79,20 @@ jobs: if: steps.changesets.outputs.published == 'false' uses: ./.github/actions/publish-beta + # Coverage Matrix: build dashboard and deploy to GitHub Pages + - name: Generate coverage matrix and build dashboard + continue-on-error: true + run: NX_CLOUD_DISTRIBUTED_EXECUTION=false pnpm nx run @forgerock/coverage-matrix:coverage-report + + - name: Deploy coverage dashboard to GitHub Pages + continue-on-error: true + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./tools/coverage-matrix/dist + destination_dir: coverage-matrix + keep_files: true + - name: Calculate baseline bundle sizes run: | chmod +x ./scripts/bundle-sizes.sh diff --git a/.gitignore b/.gitignore index b7ab27b789..e31cbf9c18 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ test-output # Gemini local knowledge base files GEMINI.md **/GEMINI.md +.e2e-coverage/ diff --git a/.nxignore b/.nxignore new file mode 100644 index 0000000000..b5a0fdd1da --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +tools/coverage-matrix/scripts/__tests__/fixtures diff --git a/e2e/davinci-suites/package.json b/e2e/davinci-suites/package.json index aea8906def..02fd7b9a86 100644 --- a/e2e/davinci-suites/package.json +++ b/e2e/davinci-suites/package.json @@ -17,5 +17,8 @@ "main": "src/index.js", "nx": { "implicitDependencies": ["@forgerock/davinci-app", "@forgerock/mock-api-v2"] + }, + "dependencies": { + "@forgerock/e2e-shared": "workspace:*" } } diff --git a/e2e/davinci-suites/src/basic.test.ts b/e2e/davinci-suites/src/basic.test.ts index 1da585de47..df11383ee2 100644 --- a/e2e/davinci-suites/src/basic.test.ts +++ b/e2e/davinci-suites/src/basic.test.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { password, username } from './utils/demo-user.js'; diff --git a/e2e/davinci-suites/src/fido.test.ts b/e2e/davinci-suites/src/fido.test.ts index e1a38cb486..a163eb2b1d 100644 --- a/e2e/davinci-suites/src/fido.test.ts +++ b/e2e/davinci-suites/src/fido.test.ts @@ -1,4 +1,4 @@ -import { test, expect, CDPSession } from '@playwright/test'; +import { test, expect, CDPSession } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; const username = 'JSFidoUser@user.com'; diff --git a/e2e/davinci-suites/src/form-fields.test.ts b/e2e/davinci-suites/src/form-fields.test.ts index 0ad89d3e21..82c38e37c0 100644 --- a/e2e/davinci-suites/src/form-fields.test.ts +++ b/e2e/davinci-suites/src/form-fields.test.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; diff --git a/e2e/davinci-suites/src/logging.test.ts b/e2e/davinci-suites/src/logging.test.ts index 1121aba0e7..1f3aacc897 100644 --- a/e2e/davinci-suites/src/logging.test.ts +++ b/e2e/davinci-suites/src/logging.test.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { password, username } from './utils/demo-user.js'; diff --git a/e2e/davinci-suites/src/mfa.test.ts b/e2e/davinci-suites/src/mfa.test.ts index fb84521a3d..0dae889aaa 100644 --- a/e2e/davinci-suites/src/mfa.test.ts +++ b/e2e/davinci-suites/src/mfa.test.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { password } from './utils/demo-user.js'; diff --git a/e2e/davinci-suites/src/middleware.test.ts b/e2e/davinci-suites/src/middleware.test.ts index eeb8709551..409b524ff1 100644 --- a/e2e/davinci-suites/src/middleware.test.ts +++ b/e2e/davinci-suites/src/middleware.test.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { password, username } from './utils/demo-user.js'; diff --git a/e2e/davinci-suites/src/phone-number-field.test.ts b/e2e/davinci-suites/src/phone-number-field.test.ts index 2e55adbfb7..7af5f5f480 100644 --- a/e2e/davinci-suites/src/phone-number-field.test.ts +++ b/e2e/davinci-suites/src/phone-number-field.test.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { password } from './utils/demo-user.js'; test.describe('Device registration tests', () => { diff --git a/e2e/davinci-suites/src/protect.test.ts b/e2e/davinci-suites/src/protect.test.ts index 8534a6043c..f0cbd67b1e 100644 --- a/e2e/davinci-suites/src/protect.test.ts +++ b/e2e/davinci-suites/src/protect.test.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; diff --git a/e2e/davinci-suites/src/register.test.ts b/e2e/davinci-suites/src/register.test.ts index 8dfba86576..314cec17ba 100644 --- a/e2e/davinci-suites/src/register.test.ts +++ b/e2e/davinci-suites/src/register.test.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { password } from './utils/demo-user.js'; diff --git a/e2e/davinci-suites/tsconfig.json b/e2e/davinci-suites/tsconfig.json index 08841a7f56..af1c829c63 100644 --- a/e2e/davinci-suites/tsconfig.json +++ b/e2e/davinci-suites/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../shared" + }, { "path": "./tsconfig.e2e.json" } diff --git a/e2e/device-client-app/vite.config.ts b/e2e/device-client-app/vite.config.ts index f54af97750..fd13aa7c79 100644 --- a/e2e/device-client-app/vite.config.ts +++ b/e2e/device-client-app/vite.config.ts @@ -4,7 +4,7 @@ import * as path from 'path'; const pages = ['oath', 'push', 'webauthn', 'device-binding', 'device-profile']; -export default defineConfig(() => ({ +export default defineConfig({ root: __dirname + '/src', cacheDir: '../../node_modules/.vite/e2e/device-client-app', publicDir: __dirname + '/public', @@ -25,7 +25,6 @@ export default defineConfig(() => ({ 'Access-Control-Allow-Origin': 'http://localhost:8443', }, }, - plugins: [], // Uncomment this if you are using workers. // worker: { // plugins: [ nxViteTsPaths() ], @@ -53,4 +52,4 @@ export default defineConfig(() => ({ }, }, }, -})); +}); diff --git a/e2e/journey-suites/package.json b/e2e/journey-suites/package.json index dd4b6405e7..b7ce8093fc 100644 --- a/e2e/journey-suites/package.json +++ b/e2e/journey-suites/package.json @@ -17,5 +17,8 @@ "main": "src/index.js", "nx": { "implicitDependencies": ["@forgerock/journey-app", "@forgerock/mock-api-v2"] + }, + "dependencies": { + "@forgerock/e2e-shared": "workspace:*" } } diff --git a/e2e/journey-suites/src/choice-confirm-poll.test.ts b/e2e/journey-suites/src/choice-confirm-poll.test.ts index 4c800011f9..4893a37654 100644 --- a/e2e/journey-suites/src/choice-confirm-poll.test.ts +++ b/e2e/journey-suites/src/choice-confirm-poll.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/src/custom-paths.test.ts b/e2e/journey-suites/src/custom-paths.test.ts index 8cd43b7863..93c2c0b3bf 100644 --- a/e2e/journey-suites/src/custom-paths.test.ts +++ b/e2e/journey-suites/src/custom-paths.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/src/device-profile.test.ts b/e2e/journey-suites/src/device-profile.test.ts index e5770b3262..3009bdfd15 100644 --- a/e2e/journey-suites/src/device-profile.test.ts +++ b/e2e/journey-suites/src/device-profile.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/src/email-suspend.test.ts b/e2e/journey-suites/src/email-suspend.test.ts index 4e4d180d87..58557b4451 100644 --- a/e2e/journey-suites/src/email-suspend.test.ts +++ b/e2e/journey-suites/src/email-suspend.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/src/login.test.ts b/e2e/journey-suites/src/login.test.ts index 796381ea0f..926a18a333 100644 --- a/e2e/journey-suites/src/login.test.ts +++ b/e2e/journey-suites/src/login.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { password, username } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/src/no-session.test.ts b/e2e/journey-suites/src/no-session.test.ts index 679e6e9cba..a8176bd843 100644 --- a/e2e/journey-suites/src/no-session.test.ts +++ b/e2e/journey-suites/src/no-session.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/src/otp-register.test.ts b/e2e/journey-suites/src/otp-register.test.ts index e743d4301f..0d6e42e1c2 100644 --- a/e2e/journey-suites/src/otp-register.test.ts +++ b/e2e/journey-suites/src/otp-register.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/src/protect.test.ts b/e2e/journey-suites/src/protect.test.ts index 09e37c4920..00197163b1 100644 --- a/e2e/journey-suites/src/protect.test.ts +++ b/e2e/journey-suites/src/protect.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/src/qr-code.test.ts b/e2e/journey-suites/src/qr-code.test.ts index b18ebd2d4d..1bff410bbc 100644 --- a/e2e/journey-suites/src/qr-code.test.ts +++ b/e2e/journey-suites/src/qr-code.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/src/recovery-codes.test.ts b/e2e/journey-suites/src/recovery-codes.test.ts index ce90a62607..885a348315 100644 --- a/e2e/journey-suites/src/recovery-codes.test.ts +++ b/e2e/journey-suites/src/recovery-codes.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; test.describe('Recovery Codes Journey', () => { diff --git a/e2e/journey-suites/src/registration.test.ts b/e2e/journey-suites/src/registration.test.ts index 6bf6725ad0..6b90a2c765 100644 --- a/e2e/journey-suites/src/registration.test.ts +++ b/e2e/journey-suites/src/registration.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { password } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/src/request-middleware.test.ts b/e2e/journey-suites/src/request-middleware.test.ts index 0e33d0b989..ad6323817a 100644 --- a/e2e/journey-suites/src/request-middleware.test.ts +++ b/e2e/journey-suites/src/request-middleware.test.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; diff --git a/e2e/journey-suites/tsconfig.json b/e2e/journey-suites/tsconfig.json index 08841a7f56..af1c829c63 100644 --- a/e2e/journey-suites/tsconfig.json +++ b/e2e/journey-suites/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../shared" + }, { "path": "./tsconfig.e2e.json" } diff --git a/e2e/oidc-app/vite.config.ts b/e2e/oidc-app/vite.config.ts index d2a956b1a9..e21220f211 100644 --- a/e2e/oidc-app/vite.config.ts +++ b/e2e/oidc-app/vite.config.ts @@ -5,7 +5,8 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pages = ['ping-am', 'ping-one']; -export default defineConfig(() => ({ + +export default defineConfig({ root: __dirname + '/src', cacheDir: '../../node_modules/.vite/e2e/oidc-app', publicDir: __dirname + '/public', @@ -17,7 +18,6 @@ export default defineConfig(() => ({ port: 8443, host: 'localhost', }, - plugins: [], // Uncomment this if you are using workers. // worker: { // plugins: [ nxViteTsPaths() ], @@ -42,4 +42,4 @@ export default defineConfig(() => ({ }, }, }, -})); +}); diff --git a/e2e/oidc-suites/package.json b/e2e/oidc-suites/package.json index e42863d302..0bada1dea3 100644 --- a/e2e/oidc-suites/package.json +++ b/e2e/oidc-suites/package.json @@ -2,7 +2,11 @@ "name": "@forgerock/oidc-suites", "version": "0.0.1", "private": true, + "type": "module", "nx": { "implicitDependencies": ["oidc-suites"] + }, + "dependencies": { + "@forgerock/e2e-shared": "workspace:*" } } diff --git a/e2e/oidc-suites/src/login.spec.ts b/e2e/oidc-suites/src/login.spec.ts index 1d1523bc13..b8dc1752f2 100644 --- a/e2e/oidc-suites/src/login.spec.ts +++ b/e2e/oidc-suites/src/login.spec.ts @@ -6,7 +6,7 @@ * of the MIT license. See the LICENSE file for details. * */ -import { test, expect } from '@playwright/test'; +import { test, expect } from '@forgerock/e2e-shared/coverage-fixture'; import { pingAmUsername, pingAmPassword, diff --git a/e2e/oidc-suites/src/logout.spec.ts b/e2e/oidc-suites/src/logout.spec.ts index ae4065301e..72c20559ad 100644 --- a/e2e/oidc-suites/src/logout.spec.ts +++ b/e2e/oidc-suites/src/logout.spec.ts @@ -6,7 +6,7 @@ * of the MIT license. See the LICENSE file for details. * */ -import { test, expect } from '@playwright/test'; +import { test, expect } from '@forgerock/e2e-shared/coverage-fixture'; import { pingAmUsername, pingAmPassword, diff --git a/e2e/oidc-suites/src/token.spec.ts b/e2e/oidc-suites/src/token.spec.ts index 083d14be1d..b27cd8d729 100644 --- a/e2e/oidc-suites/src/token.spec.ts +++ b/e2e/oidc-suites/src/token.spec.ts @@ -6,7 +6,7 @@ * of the MIT license. See the LICENSE file for details. * */ -import { test, expect } from '@playwright/test'; +import { test, expect } from '@forgerock/e2e-shared/coverage-fixture'; import { pingAmUsername, pingAmPassword, diff --git a/e2e/oidc-suites/src/user.spec.ts b/e2e/oidc-suites/src/user.spec.ts index 1fbb847840..b919108fe0 100644 --- a/e2e/oidc-suites/src/user.spec.ts +++ b/e2e/oidc-suites/src/user.spec.ts @@ -6,7 +6,7 @@ * of the MIT license. See the LICENSE file for details. * */ -import { test, expect } from '@playwright/test'; +import { test, expect } from '@forgerock/e2e-shared/coverage-fixture'; import { pingAmUsername, pingAmPassword, diff --git a/e2e/oidc-suites/tsconfig.json b/e2e/oidc-suites/tsconfig.json index 1df867f3a5..70a05212c5 100644 --- a/e2e/oidc-suites/tsconfig.json +++ b/e2e/oidc-suites/tsconfig.json @@ -21,5 +21,10 @@ "eslint.config.js", "eslint.config.mjs", "eslint.config.cjs" + ], + "references": [ + { + "path": "../shared" + } ] } diff --git a/e2e/protect-suites/package.json b/e2e/protect-suites/package.json index 3cf09710c8..717c7a51d2 100644 --- a/e2e/protect-suites/package.json +++ b/e2e/protect-suites/package.json @@ -17,5 +17,8 @@ "main": "src/index.js", "nx": { "implicitDependencies": ["@forgerock/protect-app"] + }, + "dependencies": { + "@forgerock/e2e-shared": "workspace:*" } } diff --git a/e2e/protect-suites/src/protect-native.test.ts b/e2e/protect-suites/src/protect-native.test.ts index 077117092b..f6137342e8 100644 --- a/e2e/protect-suites/src/protect-native.test.ts +++ b/e2e/protect-suites/src/protect-native.test.ts @@ -7,7 +7,7 @@ * */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '@forgerock/e2e-shared/coverage-fixture'; import { password, username } from './utils/demo-user.js'; test.describe('Test basic login flow with Ping Protect', () => { diff --git a/e2e/protect-suites/tsconfig.json b/e2e/protect-suites/tsconfig.json index 08841a7f56..af1c829c63 100644 --- a/e2e/protect-suites/tsconfig.json +++ b/e2e/protect-suites/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../shared" + }, { "path": "./tsconfig.e2e.json" } diff --git a/e2e/shared/coverage-fixture.ts b/e2e/shared/coverage-fixture.ts new file mode 100644 index 0000000000..c57ab8218d --- /dev/null +++ b/e2e/shared/coverage-fixture.ts @@ -0,0 +1,58 @@ +/** + * Playwright fixture that collects V8 code coverage via Chrome DevTools Protocol. + * + * Uses Playwright's built-in page.coverage API โ€” no Istanbul instrumentation, + * no Vite plugins, no build changes. The browser itself tracks which functions + * were executed during each test. + * + * Coverage data is written to `.e2e-coverage/` as JSON files. The + * collect-e2e-coverage.ts script reads these and maps them back to SDK + * source files using source map data. + * + * Import this instead of `@playwright/test` in e2e test files: + * import { test, expect } from '@forgerock/e2e-shared/coverage-fixture'; + */ +import { test as base, type Page, type TestInfo } from '@playwright/test'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; + +/** + * Resolve the coverage output directory relative to the test file's suite directory. + * + * Playwright tests run with cwd set to the suite root (e.g. e2e/oidc-suites). + * Writing coverage into {cwd}/.e2e-coverage keeps it inside the project so + * Nx Cloud DTE agents transfer it back as a declared output. + */ +const COVERAGE_DIR = join(process.cwd(), '.e2e-coverage'); + +export const test = base.extend({ + page: async ({ page }, use, testInfo) => { + // Start V8 JS coverage before the test runs + await page.coverage.startJSCoverage({ reportAnonymousScripts: false }); + + await use(page); + + // Collect coverage after test completes + try { + const entries = await page.coverage.stopJSCoverage(); + if (entries.length > 0) { + mkdirSync(COVERAGE_DIR, { recursive: true }); + const hash = createHash('md5').update(testInfo.titlePath.join('/')).digest('hex'); + writeFileSync(join(COVERAGE_DIR, `${hash}.json`), JSON.stringify(entries)); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('Target closed') || message.includes('has been closed')) { + // Expected: page closed before coverage could be collected + } else { + console.warn( + `[coverage] Failed to collect for "${testInfo.titlePath.join(' > ')}": ${message}`, + ); + } + } + }, +}); + +export { expect } from '@playwright/test'; +export type { CDPSession } from '@playwright/test'; diff --git a/e2e/shared/package.json b/e2e/shared/package.json new file mode 100644 index 0000000000..54782bc4b0 --- /dev/null +++ b/e2e/shared/package.json @@ -0,0 +1,12 @@ +{ + "name": "@forgerock/e2e-shared", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./coverage-fixture": "./coverage-fixture.ts" + }, + "peerDependencies": { + "@playwright/test": "*" + } +} diff --git a/e2e/shared/tsconfig.json b/e2e/shared/tsconfig.json new file mode 100644 index 0000000000..b0f475c11f --- /dev/null +++ b/e2e/shared/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "sourceMap": false, + "module": "Node16", + "moduleResolution": "Node16" + }, + "include": ["*.ts"] +} diff --git a/eslint.config.mjs b/eslint.config.mjs index b38bd77ab9..ddd6c35054 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -166,6 +166,23 @@ export default [ parser: await import('jsonc-eslint-parser'), }, }, + { + files: ['e2e/*-suites/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@playwright/test', + message: + 'Import from @forgerock/e2e-shared/coverage-fixture instead for automatic V8 coverage collection.', + }, + ], + }, + ], + }, + }, { ignores: ['**/*.md', 'dist/*', '**/**/tsconfig.spec.vitest-temp.json'], }, diff --git a/nx.json b/nx.json index d2f5805f60..7e53babe17 100644 --- a/nx.json +++ b/nx.json @@ -38,7 +38,7 @@ "e2e": { "dependsOn": ["^e2e"], "inputs": ["default", "^default"], - "outputs": ["{projectRoot}/.playwright"], + "outputs": ["{projectRoot}/.playwright", "{projectRoot}/.e2e-coverage"], "cache": true }, "lint": { @@ -61,7 +61,7 @@ "e2e-ci--**/*": { "dependsOn": ["^build"], "inputs": ["default", "^default"], - "outputs": ["{projectRoot}/.playwright"], + "outputs": ["{projectRoot}/.playwright", "{projectRoot}/.e2e-coverage"], "cache": true }, "@nx/js:tsc": { diff --git a/package.json b/package.json index 50662075de..c4ea660fc6 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "path": "./node_modules/cz-conventional-changelog" } }, - "dependencies": {}, "devDependencies": { "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f7f3fa707..656456ec96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -308,7 +308,11 @@ importers: specifier: workspace:* version: link:../../packages/sdk-effects/logger - e2e/davinci-suites: {} + e2e/davinci-suites: + dependencies: + '@forgerock/e2e-shared': + specifier: workspace:* + version: link:../shared e2e/device-client-app: dependencies: @@ -341,7 +345,11 @@ importers: specifier: workspace:* version: link:../../packages/sdk-effects/logger - e2e/journey-suites: {} + e2e/journey-suites: + dependencies: + '@forgerock/e2e-shared': + specifier: workspace:* + version: link:../shared e2e/mock-api-v2: dependencies: @@ -392,7 +400,11 @@ importers: specifier: workspace:* version: link:../../packages/oidc-client - e2e/oidc-suites: {} + e2e/oidc-suites: + dependencies: + '@forgerock/e2e-shared': + specifier: workspace:* + version: link:../shared e2e/protect-app: dependencies: @@ -403,7 +415,17 @@ importers: specifier: workspace:* version: link:../../packages/protect - e2e/protect-suites: {} + e2e/protect-suites: + dependencies: + '@forgerock/e2e-shared': + specifier: workspace:* + version: link:../shared + + e2e/shared: + dependencies: + '@playwright/test': + specifier: '*' + version: 1.56.1 packages/davinci-client: dependencies: @@ -574,6 +596,24 @@ importers: specifier: 4.20.6 version: 4.20.6 + tools/coverage-matrix: + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.55.0)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)) + glob: + specifier: ^11.0.0 + version: 11.1.0 + svelte: + specifier: ^5.0.0 + version: 5.55.0 + tsx: + specifier: ^4.0.0 + version: 4.20.6 + vite: + specifier: catalog:vite + version: 7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + tools/release: dependencies: '@effect/platform': @@ -2004,6 +2044,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -2878,6 +2922,26 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + '@swc-node/core@1.14.1': resolution: {integrity: sha512-jrt5GUaZUU6cmMS+WTJEvGvaB6j1YNKPHPzC2PUi2BjaFbtxURHj6641Az6xN7b665hNniAIdvjxWcRml5yCnw==} engines: {node: '>= 10'} @@ -3134,6 +3198,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -3682,6 +3749,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -3768,6 +3839,10 @@ packages: axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + b4a@1.7.3: resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} peerDependencies: @@ -3837,6 +3912,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} peerDependencies: @@ -3897,6 +3976,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -4081,6 +4164,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -4525,6 +4612,9 @@ packages: peerDependencies: typescript: ^5.4.4 + devalue@5.6.4: + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} + dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -4822,6 +4912,9 @@ packages: jiti: optional: true + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4839,6 +4932,9 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} + esrap@2.2.4: + resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -5251,11 +5347,18 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} @@ -5662,6 +5765,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -5794,6 +5900,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} @@ -6014,6 +6124,10 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -6049,6 +6163,9 @@ packages: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -6298,6 +6415,10 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -6714,6 +6835,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} @@ -7516,6 +7641,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte@5.55.0: + resolution: {integrity: sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==} + engines: {node: '>=18'} + swc-loader@0.2.6: resolution: {integrity: sha512-9Zi9UP2YmDpgmQVbyOPJClY0dwf58JDyDMQ7uRc4krmc72twNI2fvlBWHLqVekBpPc7h5NJkGVT1zNDxFrqhvg==} peerDependencies: @@ -8021,6 +8150,14 @@ packages: yaml: optional: true + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + vitest-canvas-mock@1.1.3: resolution: {integrity: sha512-zlKJR776Qgd+bcACPh0Pq5MG3xWq+CdkACKY/wX4Jyija0BSz8LH3aCCgwFKYFwtm565+050YFEGG9Ki0gE/Hw==} peerDependencies: @@ -8246,6 +8383,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod-package-json@1.2.0: resolution: {integrity: sha512-tamtgPM3MkP+obfO2dLr/G+nYoYkpJKmuHdYEy6IXRKfLybruoJ5NUj0lM0LxwOpC9PpoGLbll1ecoeyj43Wsg==} engines: {node: '>=20'} @@ -9820,6 +9960,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -10863,6 +11005,32 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@sveltejs/acorn-typescript@1.0.9(acorn@8.15.0)': + dependencies: + acorn: 8.15.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.0)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.55.0)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.55.0)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)) + debug: 4.4.3 + svelte: 5.55.0 + vite: 7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.0)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.0)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.55.0)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.55.0 + vite: 7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + vitefu: 1.1.2(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)) + transitivePeerDependencies: + - supports-color + '@swc-node/core@1.14.1(@swc/core@1.11.21(@swc/helpers@0.5.17))(@swc/types@0.1.25)': dependencies: '@swc/core': 1.11.21(@swc/helpers@0.5.17) @@ -11137,6 +11305,8 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': {} + '@types/unist@3.0.3': {} '@types/yargs-parser@21.0.3': {} @@ -11893,6 +12063,8 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.1: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -11993,6 +12165,8 @@ snapshots: transitivePeerDependencies: - debug + axobject-query@4.1.0: {} + b4a@1.7.3: {} babel-jest@30.2.0(@babel/core@7.28.5): @@ -12095,6 +12269,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bare-events@2.8.2: {} base64-js@1.5.1: {} @@ -12178,6 +12354,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -12349,6 +12529,8 @@ snapshots: clone@1.0.4: {} + clsx@2.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -12763,6 +12945,8 @@ snapshots: transitivePeerDependencies: - supports-color + devalue@5.6.4: {} + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -13181,6 +13365,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm-env@1.2.2: {} + espree@10.4.0: dependencies: acorn: 8.15.0 @@ -13199,6 +13385,11 @@ snapshots: dependencies: estraverse: 5.3.0 + esrap@2.2.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.46.3 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -13706,6 +13897,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.4 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -14155,6 +14355,10 @@ snapshots: is-promise@4.0.0: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -14281,6 +14485,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + jake@10.9.4: dependencies: async: 3.2.6 @@ -14693,6 +14901,8 @@ snapshots: kind-of@6.0.3: {} + kleur@4.1.5: {} + leven@3.1.0: {} levn@0.4.1: @@ -14736,6 +14946,8 @@ snapshots: loader-runner@4.3.1: {} + locate-character@3.0.0: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -14940,6 +15152,10 @@ snapshots: mimic-response@4.0.0: {} + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -15407,6 +15623,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.2 + minipass: 7.1.2 + path-to-regexp@0.1.12: {} path-to-regexp@6.3.0: {} @@ -16331,6 +16552,25 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte@5.55.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.15.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.6.4 + esm-env: 1.2.2 + esrap: 2.2.4 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + swc-loader@0.2.6(@swc/core@1.11.21(@swc/helpers@0.5.17))(webpack@5.102.1(@swc/core@1.11.21(@swc/helpers@0.5.17))): dependencies: '@swc/core': 1.11.21(@swc/helpers@0.5.17) @@ -16879,6 +17119,10 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 + vitefu@1.1.2(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)): + optionalDependencies: + vite: 7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + vitest-canvas-mock@1.1.3(vitest@3.2.4): dependencies: cssfontparser: 1.2.1 @@ -17182,6 +17426,8 @@ snapshots: yoctocolors-cjs@2.1.3: {} + zimmerframe@1.1.4: {} + zod-package-json@1.2.0: dependencies: zod: 3.25.76 diff --git a/tools/coverage-matrix/.gitignore b/tools/coverage-matrix/.gitignore new file mode 100644 index 0000000000..f04f5e9c38 --- /dev/null +++ b/tools/coverage-matrix/.gitignore @@ -0,0 +1 @@ +data/*.json diff --git a/tools/coverage-matrix/data/.gitkeep b/tools/coverage-matrix/data/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/coverage-matrix/eslint.config.mjs b/tools/coverage-matrix/eslint.config.mjs new file mode 100644 index 0000000000..e08e3e360a --- /dev/null +++ b/tools/coverage-matrix/eslint.config.mjs @@ -0,0 +1,3 @@ +import rootConfig from '../../eslint.config.mjs'; + +export default [...rootConfig]; diff --git a/tools/coverage-matrix/index.html b/tools/coverage-matrix/index.html new file mode 100644 index 0000000000..a724c4b478 --- /dev/null +++ b/tools/coverage-matrix/index.html @@ -0,0 +1,12 @@ + + + + + + Coverage Matrix โ€” Ping JavaScript SDK + + +
+ + + diff --git a/tools/coverage-matrix/package.json b/tools/coverage-matrix/package.json new file mode 100644 index 0000000000..0032696996 --- /dev/null +++ b/tools/coverage-matrix/package.json @@ -0,0 +1,24 @@ +{ + "name": "@forgerock/coverage-matrix", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "analyze": "tsx scripts/analyze.ts", + "build": "vite build", + "collect": "tsx scripts/collect.ts", + "dev": "vite", + "generate": "tsx scripts/generate.ts", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "glob": "^11.0.0", + "svelte": "^5.0.0", + "tsx": "^4.0.0", + "vite": "catalog:vite" + }, + "nx": { + "tags": ["scope:tool"] + } +} diff --git a/tools/coverage-matrix/project.json b/tools/coverage-matrix/project.json new file mode 100644 index 0000000000..e07b45fb76 --- /dev/null +++ b/tools/coverage-matrix/project.json @@ -0,0 +1,35 @@ +{ + "name": "@forgerock/coverage-matrix", + "targets": { + "analyze": { + "command": "tsx scripts/analyze.ts", + "options": { + "cwd": "tools/coverage-matrix" + } + }, + "collect": { + "command": "tsx scripts/collect.ts", + "options": { + "cwd": "tools/coverage-matrix" + } + }, + "generate": { + "command": "tsx scripts/generate.ts", + "dependsOn": ["analyze", "collect"], + "options": { + "cwd": "tools/coverage-matrix" + }, + "outputs": ["{projectRoot}/data/coverage-matrix.json"] + }, + "coverage-report": { + "command": "tsx scripts/analyze.ts && tsx scripts/collect.ts && tsx scripts/generate.ts && vite build", + "options": { + "cwd": "tools/coverage-matrix" + }, + "outputs": [ + "{projectRoot}/data/coverage-matrix.json", + "{projectRoot}/dist" + ] + } + } +} diff --git a/tools/coverage-matrix/public/data b/tools/coverage-matrix/public/data new file mode 120000 index 0000000000..4909e06efb --- /dev/null +++ b/tools/coverage-matrix/public/data @@ -0,0 +1 @@ +../data \ No newline at end of file diff --git a/tools/coverage-matrix/scripts/__tests__/collect-e2e-coverage.test.ts b/tools/coverage-matrix/scripts/__tests__/collect-e2e-coverage.test.ts new file mode 100644 index 0000000000..2526854b6b --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/collect-e2e-coverage.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { collectE2eCoverage } from '../lib/collect-e2e-coverage.js'; + +const tmpDir = join(import.meta.dirname, '.tmp-e2e-coverage-test'); +const suiteDir = join(tmpDir, 'e2e', 'test-suite'); +const coverageDir = join(suiteDir, '.e2e-coverage'); + +function writeV8Coverage(filename: string, entries: unknown[]): void { + mkdirSync(coverageDir, { recursive: true }); + writeFileSync(join(coverageDir, filename), JSON.stringify(entries)); +} + +function makeEntry(url: string, functions: Array<{ functionName: string; count: number }>) { + return { + url, + source: '', + functions: functions.map((f) => ({ + functionName: f.functionName, + ranges: [{ startOffset: 0, endOffset: 100, count: f.count }], + })), + }; +} + +describe('collectE2eCoverage', () => { + beforeEach(() => { + mkdirSync(coverageDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns null when no .e2e-coverage directory exists', () => { + rmSync(tmpDir, { recursive: true, force: true }); + expect(collectE2eCoverage(tmpDir)).toBeNull(); + }); + + it('returns null when directory is empty', () => { + expect(collectE2eCoverage(tmpDir)).toBeNull(); + }); + + it('extracts file paths from Vite /@fs/ URLs', () => { + writeV8Coverage('test.json', [ + makeEntry('http://localhost:5829/@fs/workspace/packages/foo/dist/src/lib/bar.js', [ + { functionName: '', count: 1 }, + { functionName: 'doStuff', count: 1 }, + ]), + ]); + + const result = collectE2eCoverage(tmpDir); + expect(result).not.toBeNull(); + expect(result!.coveredFiles).toContain('/workspace/packages/foo/dist/src/lib/bar.js'); + }); + + it('filters out non-package URLs', () => { + writeV8Coverage('test.json', [ + makeEntry('http://localhost:5829/@vite/client', [ + { functionName: '', count: 1 }, + { functionName: 'setup', count: 1 }, + ]), + ]); + + const result = collectE2eCoverage(tmpDir); + expect(result).toBeNull(); + }); + + it('filters out modules with only the wrapper function called', () => { + writeV8Coverage('test.json', [ + makeEntry( + 'http://localhost:5829/@fs/workspace/packages/foo/dist/src/lib/bar.js', + [{ functionName: '', count: 1 }], // Only module wrapper + ), + ]); + + const result = collectE2eCoverage(tmpDir); + expect(result).toBeNull(); + }); + + it('includes modules with more than one function called', () => { + writeV8Coverage('test.json', [ + makeEntry('http://localhost:5829/@fs/workspace/packages/foo/dist/src/lib/bar.js', [ + { functionName: '', count: 1 }, + { functionName: 'myFunction', count: 3 }, + ]), + ]); + + const result = collectE2eCoverage(tmpDir); + expect(result).not.toBeNull(); + expect(result!.coveredFiles).toHaveLength(1); + }); + + it('merges coverage across multiple JSON files', () => { + writeV8Coverage('test1.json', [ + makeEntry('http://localhost:5829/@fs/workspace/packages/foo/dist/src/lib/a.js', [ + { functionName: '', count: 1 }, + { functionName: 'fnA', count: 1 }, + ]), + ]); + writeV8Coverage('test2.json', [ + makeEntry('http://localhost:5829/@fs/workspace/packages/foo/dist/src/lib/b.js', [ + { functionName: '', count: 1 }, + { functionName: 'fnB', count: 1 }, + ]), + ]); + + const result = collectE2eCoverage(tmpDir); + expect(result).not.toBeNull(); + expect(result!.coveredFiles).toHaveLength(2); + }); + + it('skips malformed JSON files and continues', () => { + mkdirSync(coverageDir, { recursive: true }); + writeFileSync(join(coverageDir, 'bad.json'), 'not valid json{{{'); + writeV8Coverage('good.json', [ + makeEntry('http://localhost:5829/@fs/workspace/packages/foo/dist/src/lib/bar.js', [ + { functionName: '', count: 1 }, + { functionName: 'fn', count: 1 }, + ]), + ]); + + const result = collectE2eCoverage(tmpDir); + expect(result).not.toBeNull(); + expect(result!.coveredFiles).toHaveLength(1); + }); +}); diff --git a/tools/coverage-matrix/scripts/__tests__/discover-packages.test.ts b/tools/coverage-matrix/scripts/__tests__/discover-packages.test.ts new file mode 100644 index 0000000000..9444b855cc --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/discover-packages.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { resolveExportPaths, discoverPackageExportPaths } from '../lib/discover-packages.js'; +import { join } from 'node:path'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +describe('resolveExportPaths', () => { + it('extracts code entry points from simple string exports', () => { + const result = resolveExportPaths({ + '.': './dist/src/index.js', + './feature': './dist/src/lib/feature/index.js', + './types': './dist/src/types.d.ts', + './package.json': './package.json', + }); + + expect(result).toEqual([ + { name: '.', distPath: './dist/src/index.js', sourcePath: './src/index.ts' }, + { + name: './feature', + distPath: './dist/src/lib/feature/index.js', + sourcePath: './src/lib/feature/index.ts', + }, + { name: './types', distPath: './dist/src/types.d.ts', sourcePath: './src/types.ts' }, + ]); + }); + + it('extracts code entry points from conditional object exports', () => { + const result = resolveExportPaths({ + '.': { + types: './dist/src/index.d.ts', + import: './dist/src/index.js', + default: './dist/src/index.js', + }, + './constants': { + types: './dist/src/lib/constants/index.d.ts', + import: './dist/src/lib/constants/index.js', + default: './dist/src/lib/constants/index.js', + }, + './package.json': './package.json', + './types': { + types: './dist/src/types.d.ts', + import: './dist/src/types.js', + default: './dist/src/types.js', + }, + }); + + expect(result).toEqual([ + { name: '.', distPath: './dist/src/index.js', sourcePath: './src/index.ts' }, + { + name: './constants', + distPath: './dist/src/lib/constants/index.js', + sourcePath: './src/lib/constants/index.ts', + }, + { name: './types', distPath: './dist/src/types.js', sourcePath: './src/types.ts' }, + ]); + }); + + it('uses default key when import key is absent', () => { + const result = resolveExportPaths({ + '.': { types: './dist/src/index.d.ts', default: './dist/src/index.js' }, + }); + + expect(result).toEqual([ + { name: '.', distPath: './dist/src/index.js', sourcePath: './src/index.ts' }, + ]); + }); + + it('returns empty array when exports field is undefined', () => { + const result = resolveExportPaths(undefined); + expect(result).toEqual([]); + }); +}); + +describe('discoverPackageExportPaths', () => { + it('reads package.json and returns resolved export paths', () => { + const result = discoverPackageExportPaths(join(fixturesDir, 'mock-package-simple')); + + expect(result).toEqual({ + name: '@forgerock/mock-simple', + path: expect.stringContaining('mock-package-simple'), + exportPaths: [ + { name: '.', distPath: './dist/src/index.js', sourcePath: './src/index.ts' }, + { + name: './feature', + distPath: './dist/src/lib/feature/index.js', + sourcePath: './src/lib/feature/index.ts', + }, + { name: './types', distPath: './dist/src/types.d.ts', sourcePath: './src/types.ts' }, + ], + }); + }); + + it('handles conditional exports format', () => { + const result = discoverPackageExportPaths(join(fixturesDir, 'mock-package-conditional')); + + expect(result).toEqual({ + name: '@forgerock/mock-conditional', + path: expect.stringContaining('mock-package-conditional'), + exportPaths: [ + { name: '.', distPath: './dist/src/index.js', sourcePath: './src/index.ts' }, + { + name: './constants', + distPath: './dist/src/lib/constants/index.js', + sourcePath: './src/lib/constants/index.ts', + }, + { name: './types', distPath: './dist/src/types.js', sourcePath: './src/types.ts' }, + ], + }); + }); +}); diff --git a/tools/coverage-matrix/scripts/__tests__/extract-exports.test.ts b/tools/coverage-matrix/scripts/__tests__/extract-exports.test.ts new file mode 100644 index 0000000000..f5c7aa4488 --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/extract-exports.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { extractExportsFromFile } from '../lib/extract-exports.js'; +import { join } from 'node:path'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +describe('extractExportsFromFile', () => { + it('extracts named exports with correct kinds from a barrel file, resolving to actual source', () => { + const barrelFile = join(fixturesDir, 'mock-package-simple', 'src', 'index.ts'); + const actualSource = join(fixturesDir, 'mock-package-simple', 'src', 'lib', 'foo.ts'); + const result = extractExportsFromFile(barrelFile); + + // Re-exports should resolve to the actual declaration file, not the barrel + expect(result).toEqual( + expect.arrayContaining([ + { name: 'greet', kind: 'function', sourceFile: actualSource }, + { name: 'DEFAULT_NAME', kind: 'constant', sourceFile: actualSource }, + { name: 'GreetOptions', kind: 'type', sourceFile: actualSource }, + ]), + ); + expect(result).toHaveLength(3); + }); + + it('extracts exports directly from a source file with declarations', () => { + const sourceFile = join(fixturesDir, 'mock-package-simple', 'src', 'lib', 'foo.ts'); + const result = extractExportsFromFile(sourceFile); + + expect(result).toEqual( + expect.arrayContaining([ + { name: 'greet', kind: 'function', sourceFile }, + { name: 'DEFAULT_NAME', kind: 'constant', sourceFile }, + { name: 'GreetOptions', kind: 'type', sourceFile }, + { name: 'Greeter', kind: 'class', sourceFile }, + ]), + ); + expect(result).toHaveLength(4); + }); + + it('returns empty array for non-existent file', () => { + const result = extractExportsFromFile('/nonexistent/file.ts'); + expect(result).toEqual([]); + }); +}); diff --git a/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-conditional/package.json b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-conditional/package.json new file mode 100644 index 0000000000..83417a4d64 --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-conditional/package.json @@ -0,0 +1,22 @@ +{ + "name": "@forgerock/mock-conditional", + "version": "0.0.0", + "exports": { + ".": { + "default": "./dist/src/index.js", + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + }, + "./constants": { + "default": "./dist/src/lib/constants/index.js", + "import": "./dist/src/lib/constants/index.js", + "types": "./dist/src/lib/constants/index.d.ts" + }, + "./package.json": "./package.json", + "./types": { + "default": "./dist/src/types.js", + "import": "./dist/src/types.js", + "types": "./dist/src/types.d.ts" + } + } +} diff --git a/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/eslint.config.js b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/eslint.config.js new file mode 100644 index 0000000000..f9902475cc --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/eslint.config.js @@ -0,0 +1,2 @@ +// This fixture is intentionally not linted โ€” it exists only for test fixtures. +export default []; diff --git a/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/package.json b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/package.json new file mode 100644 index 0000000000..0ac5516990 --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/package.json @@ -0,0 +1,10 @@ +{ + "name": "@forgerock/mock-simple", + "version": "0.0.0", + "exports": { + ".": "./dist/src/index.js", + "./feature": "./dist/src/lib/feature/index.js", + "./package.json": "./package.json", + "./types": "./dist/src/types.d.ts" + } +} diff --git a/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/index.ts b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/index.ts new file mode 100644 index 0000000000..eecfdd639d --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/index.ts @@ -0,0 +1,2 @@ +export { greet, DEFAULT_NAME } from './lib/foo.js'; +export type { GreetOptions } from './lib/foo.js'; diff --git a/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/lib/foo.test.ts b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/lib/foo.test.ts new file mode 100644 index 0000000000..b3d0a88c30 --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/lib/foo.test.ts @@ -0,0 +1,7 @@ +import { greet, DEFAULT_NAME } from './foo.js'; + +describe('greet', () => { + it('greets', () => { + expect(greet(DEFAULT_NAME)).toBe('Hello, World!'); + }); +}); diff --git a/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/lib/foo.ts b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/lib/foo.ts new file mode 100644 index 0000000000..98f522e73e --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/lib/foo.ts @@ -0,0 +1,17 @@ +export interface GreetOptions { + readonly prefix: string; +} + +export const DEFAULT_NAME = 'World'; + +export function greet(name: string, options?: GreetOptions): string { + const prefix = options?.prefix ?? 'Hello'; + return `${prefix}, ${name}!`; +} + +export class Greeter { + constructor(private readonly name: string) {} + greet(): string { + return `Hello, ${this.name}!`; + } +} diff --git a/tools/coverage-matrix/scripts/__tests__/map-e2e-suites.test.ts b/tools/coverage-matrix/scripts/__tests__/map-e2e-suites.test.ts new file mode 100644 index 0000000000..b849be5dc4 --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/map-e2e-suites.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { mapE2eSuitesToPackages } from '../lib/map-e2e-suites.js'; +import { join } from 'node:path'; + +const workspaceRoot = join(import.meta.dirname, '..', '..', '..', '..'); + +describe('mapE2eSuitesToPackages', () => { + it('maps davinci-suites to davinci-client package', () => { + const result = mapE2eSuitesToPackages(workspaceRoot); + + const davinciMapping = result.find((m) => m.suiteName === 'davinci-suites'); + expect(davinciMapping).toBeDefined(); + expect(davinciMapping!.packages).toContain('@forgerock/davinci-client'); + }); + + it('maps journey-suites to journey-client package', () => { + const result = mapE2eSuitesToPackages(workspaceRoot); + + const journeyMapping = result.find((m) => m.suiteName === 'journey-suites'); + expect(journeyMapping).toBeDefined(); + expect(journeyMapping!.packages).toContain('@forgerock/journey-client'); + }); + + it('returns an array of suite mappings', () => { + const result = mapE2eSuitesToPackages(workspaceRoot); + + expect(result.length).toBeGreaterThan(0); + for (const mapping of result) { + expect(mapping.suiteName).toBeTruthy(); + expect(mapping.packages.length).toBeGreaterThan(0); + } + }); +}); diff --git a/tools/coverage-matrix/scripts/__tests__/merge.test.ts b/tools/coverage-matrix/scripts/__tests__/merge.test.ts new file mode 100644 index 0000000000..1e23b7568d --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/merge.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect } from 'vitest'; +import { buildCoverageMatrix } from '../lib/merge.js'; +import type { StaticAnalysis } from '../lib/types.js'; + +const mockStaticAnalysis: StaticAnalysis = { + generatedAt: '2026-03-23T12:00:00Z', + packages: [ + { + name: '@forgerock/mock-package', + path: 'packages/mock-package', + modules: [ + { + name: '.', + sourcePath: 'src/index.ts', + exports: [ + { + name: 'doStuff', + kind: 'function', + sourceFile: '/mock/packages/mock-package/src/lib/stuff.ts', + }, + { + name: 'CONSTANT', + kind: 'constant', + sourceFile: '/mock/packages/mock-package/src/lib/stuff.ts', + }, + { + name: 'StuffOptions', + kind: 'type', + sourceFile: '/mock/packages/mock-package/src/lib/stuff.ts', + }, + ], + }, + ], + }, + ], + unitTestMappings: [ + { + testFile: 'packages/mock-package/src/lib/stuff.test.ts', + packageName: '@forgerock/mock-package', + importedExports: ['doStuff'], + }, + ], + typeTestMappings: [ + { + testFile: 'packages/mock-package/src/types.test-d.ts', + packageName: '@forgerock/mock-package', + importedExports: ['StuffOptions'], + }, + ], + e2eSuiteMappings: [ + { + suiteName: 'mock-suites', + packages: ['@forgerock/mock-package'], + packageImports: [{ packageName: '@forgerock/mock-package', importedNames: ['doStuff'] }], + }, + ], + warnings: [], +}; + +describe('buildCoverageMatrix', () => { + it('builds matrix from static analysis only', () => { + const result = buildCoverageMatrix(mockStaticAnalysis, null, '/nonexistent'); + + expect(result.source).toBe('static'); + expect(result.packages).toHaveLength(1); + + const pkg = result.packages[0]; + expect(pkg.name).toBe('@forgerock/mock-package'); + + // Summary should exclude type exports from totalExports + expect(pkg.summary.totalExports).toBe(2); // doStuff + CONSTANT (not StuffOptions) + expect(pkg.summary.unitCovered).toBe(1); // doStuff + expect(pkg.summary.uncovered).toBe(1); // CONSTANT has no coverage (not in e2e app imports) + + // Check individual exports + const doStuff = pkg.modules[0].exports.find((e) => e.name === 'doStuff'); + expect(doStuff?.coverage.unit).toEqual({ + covered: true, + testFiles: ['packages/mock-package/src/lib/stuff.test.ts'], + source: 'static', + }); + + const constant = pkg.modules[0].exports.find((e) => e.name === 'CONSTANT'); + expect(constant?.coverage.unit).toBeNull(); + + const typeExport = pkg.modules[0].exports.find((e) => e.name === 'StuffOptions'); + expect(typeExport?.kind).toBe('type'); + expect(typeExport?.coverage.unit).toBeNull(); + expect(typeExport?.coverage.e2e).toBeNull(); + }); + + it('e2e coverage requires runtime Istanbul data โ€” no static fallback', () => { + const result = buildCoverageMatrix(mockStaticAnalysis, null, '/nonexistent'); + + const pkg = result.packages[0]; + // Without Istanbul runtime data, e2e is null even if static tracing finds imports + const doStuff = pkg.modules[0].exports.find((e) => e.name === 'doStuff'); + expect(doStuff?.coverage.e2e).toBeNull(); + + const constant = pkg.modules[0].exports.find((e) => e.name === 'CONSTANT'); + expect(constant?.coverage.e2e).toBeNull(); + }); + + it('excludes type exports from e2e coverage', () => { + const result = buildCoverageMatrix(mockStaticAnalysis, null, '/nonexistent'); + const pkg = result.packages[0]; + const typeExport = pkg.modules[0].exports.find((e) => e.name === 'StuffOptions'); + expect(typeExport?.coverage.e2e).toBeNull(); + }); + + it('uses runtime V8 coverage when available', () => { + const runtimeCoverage = { + unitCoverage: [ + { + packageName: '@forgerock/mock-package', + coveredFiles: ['/mock/packages/mock-package/src/lib/stuff.ts'], + }, + ], + e2eCoverage: [], + }; + + const result = buildCoverageMatrix(mockStaticAnalysis, runtimeCoverage, '/nonexistent'); + + expect(result.source).toBe('hybrid'); + + const pkg = result.packages[0]; + // Both doStuff and CONSTANT come from stuff.ts which is in coverage data + const doStuff = pkg.modules[0].exports.find((e) => e.name === 'doStuff'); + expect(doStuff?.coverage.unit).toEqual({ + covered: true, + source: 'runtime', + }); + + const constant = pkg.modules[0].exports.find((e) => e.name === 'CONSTANT'); + expect(constant?.coverage.unit).toEqual({ + covered: true, + source: 'runtime', + }); + + // Type exports still excluded + const typeExport = pkg.modules[0].exports.find((e) => e.name === 'StuffOptions'); + expect(typeExport?.coverage.unit).toBeNull(); + + // Summary reflects runtime coverage + expect(pkg.summary.unitCovered).toBe(2); + expect(pkg.summary.uncovered).toBe(0); + }); + + it('falls back to static when export file not in runtime coverage', () => { + const runtimeCoverage = { + unitCoverage: [ + { + packageName: '@forgerock/mock-package', + coveredFiles: ['/mock/packages/mock-package/src/lib/other.ts'], // doesn't include stuff.ts + }, + ], + e2eCoverage: [], + }; + + const result = buildCoverageMatrix(mockStaticAnalysis, runtimeCoverage, '/nonexistent'); + + const pkg = result.packages[0]; + // doStuff has static coverage from import tracing, but no runtime + const doStuff = pkg.modules[0].exports.find((e) => e.name === 'doStuff'); + expect(doStuff?.coverage.unit?.source).toBe('static'); + + // CONSTANT has no coverage at all from either source + const constant = pkg.modules[0].exports.find((e) => e.name === 'CONSTANT'); + expect(constant?.coverage.unit).toBeNull(); + }); + + it('marks e2e coverage when runtime e2e data matches after dist path normalization', () => { + // The mock sourceFile paths are relative (e.g. 'src/lib/stuff.ts') + // In production they're absolute, but normalizeDistPath works on any path + // containing /dist/src/ โ€” so we construct a path that normalizes to match + const runtimeCoverage = { + unitCoverage: [], + e2eCoverage: [ + { + coveredFiles: [ + // normalizeDistPath: /dist/src/ โ†’ /src/, .js โ†’ .ts + '/mock/packages/mock-package/dist/src/lib/stuff.js', + ], + }, + ], + }; + + const result = buildCoverageMatrix(mockStaticAnalysis, runtimeCoverage, '/nonexistent'); + + const pkg = result.packages[0]; + const doStuff = pkg.modules[0].exports.find((e) => e.name === 'doStuff'); + expect(doStuff?.coverage.e2e).toEqual({ + covered: true, + source: 'runtime', + }); + + const constant = pkg.modules[0].exports.find((e) => e.name === 'CONSTANT'); + expect(constant?.coverage.e2e).toEqual({ + covered: true, + source: 'runtime', + }); + + const typeExport = pkg.modules[0].exports.find((e) => e.name === 'StuffOptions'); + expect(typeExport?.coverage.e2e).toBeNull(); + }); +}); diff --git a/tools/coverage-matrix/scripts/__tests__/trace-unit-tests.test.ts b/tools/coverage-matrix/scripts/__tests__/trace-unit-tests.test.ts new file mode 100644 index 0000000000..ea7ce82165 --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/trace-unit-tests.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { traceTestImports } from '../lib/trace-unit-tests.js'; +import { join } from 'node:path'; +import type { DiscoveredPackage } from '../lib/types.js'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); +const mockPkgDir = join(fixturesDir, 'mock-package-simple'); + +describe('traceTestImports', () => { + it('maps test file imports to discovered exports', () => { + const discoveredPackage: DiscoveredPackage = { + name: '@forgerock/mock-simple', + path: mockPkgDir, + modules: [ + { + name: '.', + sourcePath: 'src/index.ts', + exports: [ + { name: 'greet', kind: 'function', sourceFile: join(mockPkgDir, 'src/index.ts') }, + { + name: 'DEFAULT_NAME', + kind: 'constant', + sourceFile: join(mockPkgDir, 'src/index.ts'), + }, + { name: 'GreetOptions', kind: 'type', sourceFile: join(mockPkgDir, 'src/index.ts') }, + ], + }, + ], + }; + + const result = traceTestImports(mockPkgDir, discoveredPackage); + + expect(result).toEqual([ + { + testFile: expect.stringContaining('foo.test.ts'), + packageName: '@forgerock/mock-simple', + importedExports: expect.arrayContaining(['greet', 'DEFAULT_NAME']), + }, + ]); + // GreetOptions should NOT be in the list (not imported by the test) + expect(result[0].importedExports).not.toContain('GreetOptions'); + }); +}); diff --git a/tools/coverage-matrix/scripts/analyze.ts b/tools/coverage-matrix/scripts/analyze.ts new file mode 100644 index 0000000000..031f8fb417 --- /dev/null +++ b/tools/coverage-matrix/scripts/analyze.ts @@ -0,0 +1,117 @@ +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { findWorkspacePackages } from './lib/discover-packages.js'; +import { extractExportsFromFile } from './lib/extract-exports.js'; +import { traceTestImports, traceTypeTestImports } from './lib/trace-unit-tests.js'; +import { mapE2eSuitesToPackages } from './lib/map-e2e-suites.js'; +import type { DiscoveredPackage, DiscoveredModule, StaticAnalysis } from './lib/types.js'; + +const workspaceRoot = join(import.meta.dirname, '..', '..', '..'); +const outputDir = join(import.meta.dirname, '..', 'data'); +const outputPath = join(outputDir, 'static-analysis.json'); + +const warnings: string[] = []; + +function warn(message: string): void { + warnings.push(message); +} + +console.log('Coverage Matrix โ€” Static Analysis'); +console.log('==================================\n'); + +// Stage 1a: Discover packages and extract exports +const packageInfos = findWorkspacePackages(workspaceRoot); +console.log(`Found ${packageInfos.length} packages\n`); + +const discoveredPackages: DiscoveredPackage[] = []; + +for (const pkgInfo of packageInfos) { + const pkgDir = pkgInfo.path; + + if (pkgInfo.exportPaths.length === 0) { + warn(`${pkgInfo.name}: no code export paths found`); + continue; + } + + const modules: DiscoveredModule[] = []; + + for (const exportPath of pkgInfo.exportPaths) { + const sourceFile = join(pkgDir, exportPath.sourcePath); + const exports = extractExportsFromFile(sourceFile); + + if (exports.length === 0) { + warn( + `${pkgInfo.name} [${exportPath.name}]: source file not found or has no exports at ${exportPath.sourcePath}`, + ); + continue; + } + + modules.push({ + name: exportPath.name, + sourcePath: exportPath.sourcePath, + exports, + }); + } + + if (modules.length > 0) { + discoveredPackages.push({ + name: pkgInfo.name, + path: relative(workspaceRoot, pkgDir), + modules, + }); + } + + const totalExports = modules.reduce((sum, m) => sum + m.exports.length, 0); + console.log(` ${pkgInfo.name}: ${modules.length} modules, ${totalExports} exports`); +} + +// Stage 1b: Trace unit test imports +console.log('\nTracing unit test imports...'); +const allUnitTestMappings = discoveredPackages.flatMap((pkg) => { + const fullPath = join(workspaceRoot, pkg.path); + return traceTestImports(fullPath, pkg); +}); +console.log(` Found ${allUnitTestMappings.length} test files with matched imports`); + +// Stage 1b2: Trace type test imports +console.log('\nTracing type test imports...'); +const allTypeTestMappings = discoveredPackages.flatMap((pkg) => { + const fullPath = join(workspaceRoot, pkg.path); + return traceTypeTestImports(fullPath, pkg); +}); +console.log(` Found ${allTypeTestMappings.length} type test files with matched imports`); + +// Stage 1c: Map e2e suites +console.log('\nMapping e2e test suites...'); +const e2eSuiteMappings = mapE2eSuitesToPackages(workspaceRoot); +for (const mapping of e2eSuiteMappings) { + console.log(` ${mapping.suiteName} โ†’ ${mapping.packages.join(', ')}`); +} + +// Write output +mkdirSync(outputDir, { recursive: true }); + +const staticAnalysis: StaticAnalysis = { + generatedAt: new Date().toISOString(), + packages: discoveredPackages, + unitTestMappings: allUnitTestMappings.map((m) => ({ + ...m, + testFile: relative(workspaceRoot, m.testFile), + })), + typeTestMappings: allTypeTestMappings.map((m) => ({ + ...m, + testFile: relative(workspaceRoot, m.testFile), + })), + e2eSuiteMappings: [...e2eSuiteMappings], + warnings, +}; + +writeFileSync(outputPath, JSON.stringify(staticAnalysis, null, 2)); +console.log(`\nOutput written to ${relative(workspaceRoot, outputPath)}`); + +if (warnings.length > 0) { + console.log(`\nWarnings (${warnings.length}):`); + for (const w of warnings) { + console.warn(` โš  ${w}`); + } +} diff --git a/tools/coverage-matrix/scripts/collect.ts b/tools/coverage-matrix/scripts/collect.ts new file mode 100644 index 0000000000..f28701f662 --- /dev/null +++ b/tools/coverage-matrix/scripts/collect.ts @@ -0,0 +1,39 @@ +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { collectUnitCoverage } from './lib/collect-unit-coverage.js'; +import { collectE2eCoverage } from './lib/collect-e2e-coverage.js'; +import { findWorkspacePackages } from './lib/discover-packages.js'; +import type { RuntimeCoverage } from './lib/types.js'; + +const workspaceRoot = join(import.meta.dirname, '..', '..', '..'); +const outputDir = join(import.meta.dirname, '..', 'data'); +const outputPath = join(outputDir, 'runtime-coverage.json'); + +console.log('Coverage Matrix โ€” Collect Runtime Coverage'); +console.log('==========================================\n'); + +const packageInfos = findWorkspacePackages(workspaceRoot); +const packagePaths = packageInfos.map((p) => relative(workspaceRoot, p.path)); + +const unitCoverage = collectUnitCoverage(workspaceRoot, packagePaths); +console.log(`Collected unit coverage for ${unitCoverage.length} packages`); + +const e2eCoverage = collectE2eCoverage(workspaceRoot); +if (e2eCoverage) { + console.log( + `Collected e2e coverage: ${e2eCoverage.coveredFiles.length} SDK source files covered`, + ); +} else { + console.log( + 'No e2e coverage data found. Run e2e tests with INSTRUMENT_COVERAGE=true to collect.', + ); +} + +const output: RuntimeCoverage = { + unitCoverage, + e2eCoverage: e2eCoverage ? [e2eCoverage] : [], +}; + +mkdirSync(outputDir, { recursive: true }); +writeFileSync(outputPath, JSON.stringify(output, null, 2)); +console.log(`\nOutput written to ${relative(workspaceRoot, outputPath)}`); diff --git a/tools/coverage-matrix/scripts/generate.ts b/tools/coverage-matrix/scripts/generate.ts new file mode 100644 index 0000000000..f27f311e46 --- /dev/null +++ b/tools/coverage-matrix/scripts/generate.ts @@ -0,0 +1,50 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { buildCoverageMatrix } from './lib/merge.js'; +import type { RuntimeCoverage, StaticAnalysis } from './lib/types.js'; + +const dataDir = join(import.meta.dirname, '..', 'data'); +const staticAnalysisPath = join(dataDir, 'static-analysis.json'); +const runtimeCoveragePath = join(dataDir, 'runtime-coverage.json'); +const outputPath = join(dataDir, 'coverage-matrix.json'); + +console.log('Coverage Matrix โ€” Generate'); +console.log('==========================\n'); + +if (!existsSync(staticAnalysisPath)) { + console.error('No static-analysis.json found. Run `analyze` first.'); + process.exit(1); +} + +const staticAnalysis: StaticAnalysis = JSON.parse(readFileSync(staticAnalysisPath, 'utf-8')); +console.log(`Loaded static analysis: ${staticAnalysis.packages.length} packages`); + +let runtimeCoverage: RuntimeCoverage | null = null; +if (existsSync(runtimeCoveragePath)) { + runtimeCoverage = JSON.parse(readFileSync(runtimeCoveragePath, 'utf-8')) as RuntimeCoverage; + console.log('Loaded runtime coverage data'); +} + +const workspaceRoot = join(import.meta.dirname, '..', '..', '..'); +const matrix = buildCoverageMatrix(staticAnalysis, runtimeCoverage, workspaceRoot); + +mkdirSync(dataDir, { recursive: true }); +writeFileSync(outputPath, JSON.stringify(matrix, null, 2)); +console.log(`\nOutput written to ${relative(workspaceRoot, outputPath)}`); +console.log(`Source: ${matrix.source}`); +console.log(`Packages: ${matrix.packages.length}`); + +for (const pkg of matrix.packages) { + const { summary } = pkg; + const unitPct = + summary.totalExports > 0 ? Math.round((summary.unitCovered / summary.totalExports) * 100) : 0; + const e2ePct = + summary.totalExports > 0 ? Math.round((summary.e2eCovered / summary.totalExports) * 100) : 0; + const filePct = + summary.totalSourceFiles > 0 + ? Math.round((summary.unitTestedFiles / summary.totalSourceFiles) * 100) + : 0; + console.log( + ` ${pkg.name}: ${summary.totalExports} exports, unit ${unitPct}%, e2e ${e2ePct}%, files ${summary.unitTestedFiles}/${summary.totalSourceFiles} (${filePct}%), ${summary.uncovered} uncovered`, + ); +} diff --git a/tools/coverage-matrix/scripts/lib/collect-e2e-coverage.ts b/tools/coverage-matrix/scripts/lib/collect-e2e-coverage.ts new file mode 100644 index 0000000000..1329f6ac70 --- /dev/null +++ b/tools/coverage-matrix/scripts/lib/collect-e2e-coverage.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface RuntimeE2eCoverage { + /** Absolute paths of SDK source files with at least one invoked function */ + readonly coveredFiles: readonly string[]; +} + +/** + * A single V8 coverage entry from Playwright's page.coverage.stopJSCoverage(). + * Each entry represents one script/module loaded by the browser. + */ +interface V8CoverageEntry { + url: string; + source?: string; + functions: V8FunctionCoverage[]; +} + +interface V8FunctionCoverage { + functionName: string; + ranges: Array<{ startOffset: number; endOffset: number; count: number }>; +} + +/** + * Check if a V8 coverage entry has functions that were genuinely invoked. + * + * V8 reports one anonymous function for every module (the module wrapper/body). + * In compiled ESM/CJS output, most function names are empty strings because + * they're assigned to exports (e.g. `exports.foo = function() {}`). + * + * Heuristic: if more than one function in the module was executed, real code + * ran โ€” not just the module initialization. The module wrapper alone counts + * as 1 function with count > 0. + */ +function hasGenuineInvocations(entry: V8CoverageEntry): boolean { + const called = entry.functions.filter((fn) => fn.ranges.some((r) => r.count > 0)); + + // More than 1 function called = real usage beyond module wrapper + return called.length > 1; +} + +/** + * Extract the absolute file path from a Vite dev server URL. + * + * Vite serves workspace files as: + * http://localhost:5829/@fs/Users/.../packages/journey-client/dist/src/lib/client.store.js + * + * We extract the path after `/@fs` to get the absolute filesystem path. + */ +function extractFilePath(url: string): string | null { + const fsMatch = url.match(/\/@fs(\/.*)/); + if (fsMatch) return fsMatch[1]; + return null; +} + +/** + * Reads V8 coverage JSON files from `.e2e-coverage/` directory + * (written by the Playwright coverage fixture) and extracts which + * SDK source files had functions genuinely invoked during e2e tests. + * + * Uses Playwright's built-in V8 coverage โ€” no Istanbul instrumentation + * required. The browser itself tracks function execution. + */ +/** + * Discover all `.e2e-coverage/` directories under `e2e/` suite projects. + * + * Each e2e suite writes coverage into its own project dir (e.g. + * `e2e/oidc-suites/.e2e-coverage/`) so that Nx Cloud DTE agents transfer + * the data back as declared outputs. + */ +function findCoverageDirs(workspaceRoot: string): string[] { + const e2eRoot = join(workspaceRoot, 'e2e'); + if (!existsSync(e2eRoot)) return []; + + return readdirSync(e2eRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => join(e2eRoot, d.name, '.e2e-coverage')) + .filter((dir) => existsSync(dir)); +} + +export function collectE2eCoverage(workspaceRoot: string): RuntimeE2eCoverage | null { + const coverageDirs = findCoverageDirs(workspaceRoot); + + if (coverageDirs.length === 0) return null; + + const allCoveredFiles = new Set(); + + for (const coverageDir of coverageDirs) { + const files = readdirSync(coverageDir).filter((f) => f.endsWith('.json')); + + for (const file of files) { + try { + const entries: V8CoverageEntry[] = JSON.parse( + readFileSync(join(coverageDir, file), 'utf-8'), + ); + + for (const entry of entries) { + const filePath = extractFilePath(entry.url); + if (!filePath || !filePath.includes('/packages/')) continue; + if (hasGenuineInvocations(entry)) { + allCoveredFiles.add(filePath); + } + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[collect-e2e-coverage] Skipping ${file}: ${message}`); + continue; + } + } + } + + if (allCoveredFiles.size === 0) return null; + + return { coveredFiles: [...allCoveredFiles] }; +} diff --git a/tools/coverage-matrix/scripts/lib/collect-unit-coverage.ts b/tools/coverage-matrix/scripts/lib/collect-unit-coverage.ts new file mode 100644 index 0000000000..2020499599 --- /dev/null +++ b/tools/coverage-matrix/scripts/lib/collect-unit-coverage.ts @@ -0,0 +1,58 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface RuntimeUnitCoverage { + readonly packageName: string; + /** Only files with at least one executed statement */ + readonly coveredFiles: readonly string[]; +} + +interface IstanbulFileCoverage { + s: Record; + f: Record; +} + +/** A file is "covered" only if at least one statement was actually executed */ +function hasExecutedStatements(fileCoverage: IstanbulFileCoverage): boolean { + return Object.values(fileCoverage.s).some((hits) => hits > 0); +} + +export function collectUnitCoverage( + workspaceRoot: string, + packagePaths: readonly string[], +): readonly RuntimeUnitCoverage[] { + const results: RuntimeUnitCoverage[] = []; + + for (const pkgPath of packagePaths) { + const coverageDir = join(workspaceRoot, pkgPath, 'coverage'); + const coverageJsonPath = join(coverageDir, 'coverage-final.json'); + + if (!existsSync(coverageJsonPath)) continue; + + try { + const coverageData: Record = JSON.parse( + readFileSync(coverageJsonPath, 'utf-8'), + ); + + // Only include files where at least one statement was actually hit + const coveredFiles = Object.entries(coverageData) + .filter(([, fileCov]) => hasExecutedStatements(fileCov)) + .map(([filePath]) => filePath); + + const pkgJson = JSON.parse( + readFileSync(join(workspaceRoot, pkgPath, 'package.json'), 'utf-8'), + ); + + results.push({ + packageName: pkgJson.name, + coveredFiles, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[collect-unit-coverage] Skipping ${pkgPath}: ${message}`); + continue; + } + } + + return results; +} diff --git a/tools/coverage-matrix/scripts/lib/discover-packages.ts b/tools/coverage-matrix/scripts/lib/discover-packages.ts new file mode 100644 index 0000000000..d16f2f6a31 --- /dev/null +++ b/tools/coverage-matrix/scripts/lib/discover-packages.ts @@ -0,0 +1,136 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** A single resolved export path entry. */ +export interface ExportPath { + readonly name: string; + readonly distPath: string; + readonly sourcePath: string; +} + +/** The result of discovering a package's export paths. */ +export interface DiscoveredPackageExportPaths { + readonly name: string; + readonly path: string; + readonly exportPaths: readonly ExportPath[]; +} + +type ConditionalExport = { + types?: string; + import?: string; + default?: string; + [key: string]: string | undefined; +}; + +type PackageExports = Record; + +/** The entries that should always be excluded from export path resolution. */ +const EXCLUDED_ENTRIES = new Set(['./package.json']); + +/** + * Maps a dist path (e.g. `./dist/src/index.js`) to the corresponding + * source path (e.g. `./src/index.ts`). + */ +function distToSourcePath(distPath: string): string { + return distPath + .replace(/^\.\/dist\//, './') + .replace(/\.d\.ts$/, '.ts') + .replace(/\.js$/, '.ts'); +} + +/** + * Resolves a single export value (string or conditional object) to a dist path, + * returning `null` for entries that should be excluded (types-only `.d.ts` files, + * plain `package.json` references, etc.). + */ +function resolveDistPath(value: string | ConditionalExport): string | null { + if (typeof value === 'string') { + if (value === './package.json') return null; + // Accept .d.ts โ€” it will resolve to the .ts source via distToSourcePath + return value; + } + + // Conditional export object โ€” prefer `import`, fall back to `types`, then `default` + const resolved = value['import'] ?? value['default'] ?? value['types'] ?? null; + return resolved; +} + +/** + * Takes the raw `exports` field from a `package.json` and returns the + * resolved `ExportPath[]`, filtering out non-code entries. + */ +export function resolveExportPaths(exports: PackageExports | undefined): ExportPath[] { + if (exports === undefined) return []; + + const results: ExportPath[] = []; + + for (const [name, value] of Object.entries(exports)) { + if (EXCLUDED_ENTRIES.has(name)) continue; + + const distPath = resolveDistPath(value); + if (distPath === null) continue; + + results.push({ name, distPath, sourcePath: distToSourcePath(distPath) }); + } + + return results; +} + +/** + * Reads the `package.json` at `packageDir`, resolves its export paths, and + * returns the combined result. + */ +export function discoverPackageExportPaths(packageDir: string): DiscoveredPackageExportPaths { + const pkgJsonPath = join(packageDir, 'package.json'); + const raw = readFileSync(pkgJsonPath, 'utf-8'); + const pkg = JSON.parse(raw) as { name: string; exports?: PackageExports }; + + return { + name: pkg.name, + path: packageDir, + exportPaths: resolveExportPaths(pkg.exports), + }; +} + +/** + * Scans the workspace for packages, mirroring the layout: + * - `packages/*` (excluding `test-package` and `sdk-effects/`) + * - `packages/sdk-effects/*` + * + * Returns only directories that contain a `package.json`. + */ +export function findWorkspacePackages(workspaceRoot: string): DiscoveredPackageExportPaths[] { + const packagesRoot = join(workspaceRoot, 'packages'); + + const topLevelEntries = readdirSync(packagesRoot, { withFileTypes: true }); + + const packageDirs: string[] = []; + + for (const entry of topLevelEntries) { + if (!entry.isDirectory()) continue; + if (entry.name === 'test-package') continue; + + if (entry.name === 'sdk-effects') { + // Recurse one level into sdk-effects + const sdkEffectsRoot = join(packagesRoot, 'sdk-effects'); + const subEntries = readdirSync(sdkEffectsRoot, { withFileTypes: true }); + for (const sub of subEntries) { + if (!sub.isDirectory()) continue; + packageDirs.push(join(sdkEffectsRoot, sub.name)); + } + } else { + packageDirs.push(join(packagesRoot, entry.name)); + } + } + + return packageDirs + .filter((dir) => { + try { + readFileSync(join(dir, 'package.json'), 'utf-8'); + return true; + } catch { + return false; + } + }) + .map(discoverPackageExportPaths); +} diff --git a/tools/coverage-matrix/scripts/lib/extract-exports.ts b/tools/coverage-matrix/scripts/lib/extract-exports.ts new file mode 100644 index 0000000000..c163ada86a --- /dev/null +++ b/tools/coverage-matrix/scripts/lib/extract-exports.ts @@ -0,0 +1,84 @@ +import ts from 'typescript'; +import { existsSync } from 'node:fs'; +import type { DiscoveredExport, ExportKind } from './types.js'; + +const COMPILER_OPTIONS: ts.CompilerOptions = { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ES2022, + strict: true, + noEmit: true, + allowJs: true, +}; + +function resolveKindFromSymbol(symbol: ts.Symbol, checker: ts.TypeChecker): ExportKind { + // Follow aliases to the actual declaration + let resolved = symbol; + if (symbol.flags & ts.SymbolFlags.Alias) { + resolved = checker.getAliasedSymbol(symbol); + } + + // Check symbol flags first (covers interface and type alias) + if (resolved.flags & ts.SymbolFlags.TypeAlias) return 'type'; + if (resolved.flags & ts.SymbolFlags.Interface) return 'type'; + if (resolved.flags & ts.SymbolFlags.Class) return 'class'; + if (resolved.flags & ts.SymbolFlags.Function) return 'function'; + + // Inspect declarations for more precise classification + const decls = resolved.getDeclarations() ?? []; + for (const decl of decls) { + if (ts.isFunctionDeclaration(decl)) return 'function'; + if (ts.isClassDeclaration(decl)) return 'class'; + if (ts.isInterfaceDeclaration(decl)) return 'type'; + if (ts.isTypeAliasDeclaration(decl)) return 'type'; + } + + // Check if the original symbol was a type-only export specifier + const origDecls = symbol.getDeclarations() ?? []; + for (const decl of origDecls) { + if (ts.isExportSpecifier(decl) && decl.isTypeOnly) return 'type'; + // Also check parent export declaration for `export type { ... }` + if ( + ts.isExportSpecifier(decl) && + ts.isNamedExports(decl.parent) && + ts.isExportDeclaration(decl.parent.parent) && + decl.parent.parent.isTypeOnly + ) { + return 'type'; + } + } + + return 'constant'; +} + +export function extractExportsFromFile(filePath: string): readonly DiscoveredExport[] { + if (!existsSync(filePath)) return []; + + const program = ts.createProgram([filePath], COMPILER_OPTIONS); + const checker = program.getTypeChecker(); + + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) return []; + + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) return []; + + const exports = checker.getExportsOfModule(moduleSymbol); + + return exports.map((sym): DiscoveredExport => { + // Resolve through re-exports to find the actual declaration file + let resolved = sym; + if (sym.flags & ts.SymbolFlags.Alias) { + resolved = checker.getAliasedSymbol(sym); + } + + const decls = resolved.getDeclarations() ?? []; + const declFile = decls.length > 0 ? decls[0].getSourceFile().fileName : filePath; + + return { + name: sym.getName(), + kind: resolveKindFromSymbol(sym, checker), + sourceFile: declFile, + }; + }); +} diff --git a/tools/coverage-matrix/scripts/lib/map-e2e-suites.ts b/tools/coverage-matrix/scripts/lib/map-e2e-suites.ts new file mode 100644 index 0000000000..b2073a2dce --- /dev/null +++ b/tools/coverage-matrix/scripts/lib/map-e2e-suites.ts @@ -0,0 +1,111 @@ +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { globSync } from 'glob'; +import ts from 'typescript'; +import type { E2eSuiteMapping, E2eSuitePackageImports } from './types.js'; + +interface NamedImport { + readonly packageName: string; + readonly importedName: string; +} + +/** Extract @forgerock/* package names and named imports from a file. */ +function extractForgeRockNamedImports(filePath: string): readonly NamedImport[] { + let content: string; + try { + content = readFileSync(filePath, 'utf-8'); + } catch { + return []; + } + + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const result: NamedImport[] = []; + + for (const stmt of sourceFile.statements) { + if (!ts.isImportDeclaration(stmt)) continue; + + const specifier = stmt.moduleSpecifier; + if (!ts.isStringLiteral(specifier)) continue; + + const value = specifier.text; + if (!value.startsWith('@forgerock/')) continue; + + // Normalize to top-level package: @forgerock/davinci-client + const parts = value.split('/'); + const packageName = parts.slice(0, 2).join('/'); + + const clause = stmt.importClause; + if (!clause) continue; + + const { namedBindings } = clause; + if (namedBindings && ts.isNamedImports(namedBindings)) { + for (const element of namedBindings.elements) { + result.push({ packageName, importedName: element.name.text }); + } + } + } + + return result; +} + +/** Scan all source files in an app directory and collect named imports per package. */ +function collectNamedImportsFromApp(appDir: string): readonly E2eSuitePackageImports[] { + const files = globSync(['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], { + cwd: appDir, + absolute: true, + ignore: ['**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*'], + }); + + const packageMap = new Map>(); + + for (const file of files) { + for (const { packageName, importedName } of extractForgeRockNamedImports(file)) { + if (!packageMap.has(packageName)) { + packageMap.set(packageName, new Set()); + } + packageMap.get(packageName)!.add(importedName); + } + } + + return Array.from(packageMap.entries()).map(([packageName, names]) => ({ + packageName, + importedNames: [...names], + })); +} + +/** + * Scans the workspace `e2e/` directory, finds all `*-suites` directories, and + * maps each suite to the `@forgerock/*` packages and specific named imports + * used by its companion `*-app` directory. + * + * Uses the Nx dependency graph to include transitive dependencies โ€” + * if an e2e app imports `@forgerock/davinci-client` which depends on + * `@forgerock/sdk-request-middleware`, the middleware package is included + * as a transitive dependency with package-level (not export-level) coverage. + */ +export function mapE2eSuitesToPackages(workspaceRoot: string): readonly E2eSuiteMapping[] { + const e2eDir = join(workspaceRoot, 'e2e'); + const entries = readdirSync(e2eDir, { withFileTypes: true }); + const mappings: E2eSuiteMapping[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-suites')) continue; + + const suiteName = entry.name; + const appName = suiteName.replace(/-suites$/, '-app'); + const appDir = join(e2eDir, appName); + + if (!existsSync(appDir)) continue; + + // Direct imports from the e2e app โ€” export-level accuracy + const directImports = collectNamedImportsFromApp(appDir); + const directPackages = directImports.map((p) => p.packageName); + + if (directPackages.length === 0) continue; + + mappings.push({ suiteName, packages: directPackages, packageImports: directImports }); + } + + return mappings; +} diff --git a/tools/coverage-matrix/scripts/lib/merge.ts b/tools/coverage-matrix/scripts/lib/merge.ts new file mode 100644 index 0000000000..8469960809 --- /dev/null +++ b/tools/coverage-matrix/scripts/lib/merge.ts @@ -0,0 +1,341 @@ +import { globSync } from 'glob'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { + StaticAnalysis, + CoverageMatrix, + PackageEntry, + PackageSummary, + ModuleEntry, + ExportEntry, + ExportKind, + CoverageEntry, + RuntimeCoverage, +} from './types.js'; + +type UnitMap = Map>; +type TypeTestMap = Map>; +/** packageName โ†’ suiteNames[] (package-level) */ +type E2ePackageMap = Map; +/** packageName โ†’ exportName โ†’ suiteNames[] (export-level) */ +type E2eExportMap = Map>; +/** packageName โ†’ Set of absolute covered file paths */ +type RuntimeUnitMap = Map>; + +function buildUnitMap(staticAnalysis: StaticAnalysis): UnitMap { + const map: UnitMap = new Map(); + for (const mapping of staticAnalysis.unitTestMappings) { + if (!map.has(mapping.packageName)) { + map.set(mapping.packageName, new Map()); + } + const exportMap = map.get(mapping.packageName)!; + for (const exportName of mapping.importedExports) { + if (!exportMap.has(exportName)) { + exportMap.set(exportName, []); + } + exportMap.get(exportName)!.push(mapping.testFile); + } + } + return map; +} + +function buildE2eExportMap(staticAnalysis: StaticAnalysis): E2eExportMap { + const map: E2eExportMap = new Map(); + for (const mapping of staticAnalysis.e2eSuiteMappings) { + for (const pkgImport of mapping.packageImports) { + if (!map.has(pkgImport.packageName)) { + map.set(pkgImport.packageName, new Map()); + } + const exportMap = map.get(pkgImport.packageName)!; + for (const name of pkgImport.importedNames) { + if (!exportMap.has(name)) { + exportMap.set(name, []); + } + exportMap.get(name)!.push(mapping.suiteName); + } + } + } + return map; +} + +function buildTypeTestMap(staticAnalysis: StaticAnalysis): TypeTestMap { + const map: TypeTestMap = new Map(); + for (const mapping of staticAnalysis.typeTestMappings) { + if (!map.has(mapping.packageName)) { + map.set(mapping.packageName, new Map()); + } + const exportMap = map.get(mapping.packageName)!; + for (const exportName of mapping.importedExports) { + if (!exportMap.has(exportName)) { + exportMap.set(exportName, []); + } + exportMap.get(exportName)!.push(mapping.testFile); + } + } + return map; +} + +function buildRuntimeUnitMap(runtimeCoverage: RuntimeCoverage | null): RuntimeUnitMap { + const map: RuntimeUnitMap = new Map(); + if (!runtimeCoverage) return map; + + for (const pkg of runtimeCoverage.unitCoverage) { + map.set(pkg.packageName, new Set(pkg.coveredFiles)); + } + return map; +} + +/** + * Normalize a Vite-served dist path back to the original TypeScript source path. + * Vite dev server resolves packages from their `dist/` output, so paths look like + * `packages/foo/dist/src/lib/bar.js`. We map `dist/src/` โ†’ `src/` and `.js` โ†’ `.ts`. + */ +function normalizeDistPath(filePath: string): string { + return filePath.replace(/\/dist\/src\//, '/src/').replace(/\.js$/, '.ts'); +} + +/** + * Extract the workspace-relative portion of a coverage path. + * + * Covered file paths are absolute and originate from different machines + * (DTE agents vs main runner). We extract from the first `/packages/` or + * `/e2e/` segment onward so comparisons are machine-independent. + */ +function toWorkspaceRelative(filePath: string, workspaceRoot: string): string { + // If the path starts with the known workspace root, just strip it + if (filePath.startsWith(workspaceRoot)) { + return filePath.slice(workspaceRoot.length).replace(/^\//, ''); + } + // Otherwise, extract from known workspace directory markers + const match = filePath.match(/\/(packages\/.*)/); + if (match) return match[1]; + return filePath; +} + +/** Build a flat Set of all source files covered by e2e tests (V8/Playwright data) */ +function buildRuntimeE2eFileSet( + runtimeCoverage: RuntimeCoverage | null, + workspaceRoot: string, +): Set { + const files = new Set(); + if (!runtimeCoverage) return files; + + for (const entry of runtimeCoverage.e2eCoverage) { + for (const file of entry.coveredFiles) { + files.add(toWorkspaceRelative(normalizeDistPath(file), workspaceRoot)); + } + } + return files; +} + +function buildExportEntry( + exportDef: { name: string; kind: ExportKind; sourceFile: string }, + unitMap: Map | undefined, + typeTestMap: Map | undefined, + e2eExportMap: Map | undefined, + runtimeUnitFiles: Set | undefined, + runtimeE2eFiles: Set, + workspaceRoot: string, +): ExportEntry { + const isType = exportDef.kind === 'type'; + + let unitCoverage: CoverageEntry | null = null; + if (!isType) { + const hasRuntimeCoverage = runtimeUnitFiles?.has(exportDef.sourceFile) ?? false; + + if (hasRuntimeCoverage) { + unitCoverage = { + covered: true, + source: 'runtime', + }; + } else { + const testFiles = unitMap?.get(exportDef.name); + if (testFiles && testFiles.length > 0) { + unitCoverage = { + covered: true, + testFiles, + source: 'static', + }; + } + } + } + + // E2E coverage โ€” runtime V8 only, no static fallback. + // Only count exports whose source file had functions actually invoked. + // Compare workspace-relative paths since DTE agents have different absolute roots. + let e2eCoverage: CoverageEntry | null = null; + const relativeSourceFile = toWorkspaceRelative(exportDef.sourceFile, workspaceRoot); + if (!isType && runtimeE2eFiles.has(relativeSourceFile)) { + e2eCoverage = { + covered: true, + source: 'runtime', + }; + } + + // Type test coverage โ€” applies to type exports + let typeTestCoverage: CoverageEntry | null = null; + if (isType) { + const testFiles = typeTestMap?.get(exportDef.name); + if (testFiles && testFiles.length > 0) { + typeTestCoverage = { + covered: true, + testFiles, + source: 'static', + }; + } + } + + return { + name: exportDef.name, + kind: exportDef.kind, + coverage: { + unit: unitCoverage, + e2e: e2eCoverage, + typeTest: typeTestCoverage, + }, + }; +} + +function buildModuleEntry( + moduleDef: { + name: string; + sourcePath: string; + exports: readonly { name: string; kind: ExportKind; sourceFile: string }[]; + }, + unitMap: Map | undefined, + typeTestMap: Map | undefined, + e2eExportMap: Map | undefined, + runtimeUnitFiles: Set | undefined, + runtimeE2eFiles: Set, + workspaceRoot: string, +): ModuleEntry { + const exports = moduleDef.exports.map((exp) => + buildExportEntry( + exp, + unitMap, + typeTestMap, + e2eExportMap, + runtimeUnitFiles, + runtimeE2eFiles, + workspaceRoot, + ), + ); + return { + name: moduleDef.name, + path: moduleDef.sourcePath, + exports, + }; +} + +function buildPackageSummary( + modules: readonly ModuleEntry[], + runtimeUnitFiles: Set | undefined, + totalSourceFiles: number, +): PackageSummary { + // Deduplicate exports across modules โ€” the same symbol exported from + // both "." and "./constants" should only count once in the summary. + const seen = new Set(); + let totalExports = 0; + let unitCovered = 0; + let e2eCovered = 0; + let coveredByAny = 0; + + for (const mod of modules) { + for (const exp of mod.exports) { + if (exp.kind === 'type') continue; + if (seen.has(exp.name)) continue; + seen.add(exp.name); + + totalExports++; + const hasUnit = exp.coverage.unit?.covered ?? false; + const hasE2e = exp.coverage.e2e?.covered ?? false; + if (hasUnit) unitCovered++; + if (hasE2e) e2eCovered++; + if (hasUnit || hasE2e) coveredByAny++; + } + } + + const uncovered = totalExports - coveredByAny; + const unitTestedFiles = runtimeUnitFiles?.size ?? 0; + + return { totalExports, unitCovered, e2eCovered, uncovered, totalSourceFiles, unitTestedFiles }; +} + +function buildPackageEntry( + packageDef: StaticAnalysis['packages'][number], + unitMap: UnitMap, + typeTestMap: TypeTestMap, + e2eExportMap: E2eExportMap, + runtimeUnitMap: RuntimeUnitMap, + runtimeE2eFiles: Set, + workspaceRoot: string, +): PackageEntry { + const pkgUnitMap = unitMap.get(packageDef.name); + const pkgTypeTestMap = typeTestMap.get(packageDef.name); + const pkgE2eExportMap = e2eExportMap.get(packageDef.name); + const runtimeUnitFiles = runtimeUnitMap.get(packageDef.name); + + const modules = packageDef.modules.map((mod) => + buildModuleEntry( + mod, + pkgUnitMap, + pkgTypeTestMap, + pkgE2eExportMap, + runtimeUnitFiles, + runtimeE2eFiles, + workspaceRoot, + ), + ); + + // Count non-test, non-type source files in the package + const srcDir = join(workspaceRoot, packageDef.path, 'src'); + let totalSourceFiles = 0; + if (existsSync(srcDir)) { + totalSourceFiles = globSync(['**/*.ts'], { + cwd: srcDir, + ignore: ['**/*.test.*', '**/*.spec.*', '**/*.test-d.*', '**/*.d.ts'], + }).length; + } + + const summary = buildPackageSummary(modules, runtimeUnitFiles, totalSourceFiles); + + return { + name: packageDef.name, + path: packageDef.path, + modules, + summary, + }; +} + +export function buildCoverageMatrix( + staticAnalysis: StaticAnalysis, + runtimeCoverage: RuntimeCoverage | null, + workspaceRoot: string, +): CoverageMatrix { + const unitMap = buildUnitMap(staticAnalysis); + const typeTestMap = buildTypeTestMap(staticAnalysis); + const e2eExportMap = buildE2eExportMap(staticAnalysis); + const runtimeUnitMap = buildRuntimeUnitMap(runtimeCoverage); + const runtimeE2eFiles = buildRuntimeE2eFileSet(runtimeCoverage, workspaceRoot); + + const hasRuntime = runtimeUnitMap.size > 0 || runtimeE2eFiles.size > 0; + const source = hasRuntime ? 'hybrid' : 'static'; + + const packages = staticAnalysis.packages.map((pkg) => + buildPackageEntry( + pkg, + unitMap, + typeTestMap, + e2eExportMap, + runtimeUnitMap, + runtimeE2eFiles, + workspaceRoot, + ), + ); + + return { + generatedAt: new Date().toISOString(), + source, + packages, + }; +} diff --git a/tools/coverage-matrix/scripts/lib/trace-unit-tests.ts b/tools/coverage-matrix/scripts/lib/trace-unit-tests.ts new file mode 100644 index 0000000000..72fd75babf --- /dev/null +++ b/tools/coverage-matrix/scripts/lib/trace-unit-tests.ts @@ -0,0 +1,92 @@ +import ts from 'typescript'; +import { readFileSync } from 'node:fs'; +import { globSync } from 'glob'; +import type { DiscoveredPackage, UnitTestMapping, TypeTestMapping } from './types.js'; + +function extractImportedNames(sourceFile: ts.SourceFile): readonly string[] { + const names: string[] = []; + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + + const { importClause } = statement; + if (!importClause) continue; + + const { namedBindings } = importClause; + if (!namedBindings || !ts.isNamedImports(namedBindings)) continue; + + for (const element of namedBindings.elements) { + names.push(element.name.text); + } + } + + return names; +} + +export function traceTestImports( + packageDir: string, + discoveredPackage: DiscoveredPackage, +): readonly UnitTestMapping[] { + const testFiles = globSync('src/**/*.test.ts', { + cwd: packageDir, + absolute: true, + }); + + const knownExports = new Set( + discoveredPackage.modules.flatMap((mod) => mod.exports.map((exp) => exp.name)), + ); + + const mappings: UnitTestMapping[] = []; + + for (const testFile of testFiles) { + const content = readFileSync(testFile, 'utf-8'); + const sourceFile = ts.createSourceFile(testFile, content, ts.ScriptTarget.ES2022, true); + + const importedNames = extractImportedNames(sourceFile); + const importedExports = importedNames.filter((name) => knownExports.has(name)); + + if (importedExports.length > 0) { + mappings.push({ + testFile, + packageName: discoveredPackage.name, + importedExports, + }); + } + } + + return mappings; +} + +export function traceTypeTestImports( + packageDir: string, + discoveredPackage: DiscoveredPackage, +): readonly TypeTestMapping[] { + const testFiles = globSync('src/**/*.test-d.ts', { + cwd: packageDir, + absolute: true, + }); + + const knownExports = new Set( + discoveredPackage.modules.flatMap((mod) => mod.exports.map((exp) => exp.name)), + ); + + const mappings: TypeTestMapping[] = []; + + for (const testFile of testFiles) { + const content = readFileSync(testFile, 'utf-8'); + const sourceFile = ts.createSourceFile(testFile, content, ts.ScriptTarget.ES2022, true); + + const importedNames = extractImportedNames(sourceFile); + const importedExports = importedNames.filter((name) => knownExports.has(name)); + + if (importedExports.length > 0) { + mappings.push({ + testFile, + packageName: discoveredPackage.name, + importedExports, + }); + } + } + + return mappings; +} diff --git a/tools/coverage-matrix/scripts/lib/types.ts b/tools/coverage-matrix/scripts/lib/types.ts new file mode 100644 index 0000000000..15e4c162e4 --- /dev/null +++ b/tools/coverage-matrix/scripts/lib/types.ts @@ -0,0 +1,123 @@ +export type ExportKind = 'function' | 'class' | 'type' | 'constant'; +/** + * Confidence level of coverage data: + * - 'runtime': V8/Istanbul confirmed execution of the source file + * - 'static': direct import found in test/app source code + */ +export type CoverageSource = 'static' | 'runtime'; +export type MatrixSource = 'static' | 'runtime' | 'hybrid'; + +export interface CoverageEntry { + readonly covered: boolean; + readonly testFiles?: readonly string[]; + readonly testSuites?: readonly string[]; + readonly source: CoverageSource; +} + +export interface ExportEntry { + readonly name: string; + readonly kind: ExportKind; + readonly coverage: { + readonly unit: CoverageEntry | null; + readonly e2e: CoverageEntry | null; + readonly typeTest: CoverageEntry | null; + }; +} + +export interface ModuleEntry { + readonly name: string; + readonly path: string; + readonly exports: readonly ExportEntry[]; +} + +export interface PackageSummary { + readonly totalExports: number; + readonly unitCovered: number; + readonly e2eCovered: number; + readonly uncovered: number; + /** Total source files in the package (from V8/Istanbul data) */ + readonly totalSourceFiles: number; + /** Source files with V8-confirmed unit test execution */ + readonly unitTestedFiles: number; +} + +export interface PackageEntry { + readonly name: string; + readonly path: string; + readonly modules: readonly ModuleEntry[]; + readonly summary: PackageSummary; +} + +export interface CoverageMatrix { + readonly generatedAt: string; + readonly source: MatrixSource; + readonly packages: readonly PackageEntry[]; +} + +/** Intermediate type used during analysis before coverage is known */ +export interface DiscoveredExport { + readonly name: string; + readonly kind: ExportKind; + readonly sourceFile: string; +} + +export interface DiscoveredModule { + readonly name: string; + readonly sourcePath: string; + readonly exports: readonly DiscoveredExport[]; +} + +export interface DiscoveredPackage { + readonly name: string; + readonly path: string; + readonly modules: readonly DiscoveredModule[]; +} + +export interface StaticAnalysis { + readonly generatedAt: string; + readonly packages: readonly DiscoveredPackage[]; + readonly unitTestMappings: readonly UnitTestMapping[]; + readonly typeTestMappings: readonly TypeTestMapping[]; + readonly e2eSuiteMappings: readonly E2eSuiteMapping[]; + readonly warnings: readonly string[]; +} + +export interface UnitTestMapping { + readonly testFile: string; + readonly packageName: string; + readonly importedExports: readonly string[]; +} + +export interface TypeTestMapping { + readonly testFile: string; + readonly packageName: string; + readonly importedExports: readonly string[]; +} + +export interface E2eSuitePackageImports { + readonly packageName: string; + readonly importedNames: readonly string[]; +} + +export interface E2eSuiteMapping { + readonly suiteName: string; + readonly packages: readonly string[]; + /** Named imports per package โ€” for export-level e2e coverage */ + readonly packageImports: readonly E2eSuitePackageImports[]; +} + +export interface RuntimeCoverage { + readonly unitCoverage: readonly RuntimeUnitPackageCoverage[]; + /** Merged e2e coverage from Playwright V8 coverage collection */ + readonly e2eCoverage: readonly RuntimeE2eCoverageEntry[]; +} + +export interface RuntimeUnitPackageCoverage { + readonly packageName: string; + readonly coveredFiles: readonly string[]; +} + +export interface RuntimeE2eCoverageEntry { + /** Absolute paths of SDK source files executed during e2e tests */ + readonly coveredFiles: readonly string[]; +} diff --git a/tools/coverage-matrix/src/App.svelte b/tools/coverage-matrix/src/App.svelte new file mode 100644 index 0000000000..1e9905f0e2 --- /dev/null +++ b/tools/coverage-matrix/src/App.svelte @@ -0,0 +1,154 @@ + + +
+
+

Coverage Matrix

+ {#if matrix} +

+ Generated {new Date(matrix.generatedAt).toLocaleString()} · Source: {matrix.source} +

+ {/if} +
+ + {#if error} +

Failed to load coverage data: {error}

+ {:else if !matrix} +

Loading coverage data...

+ {:else if route.view === 'overview'} + +
+

How coverage is measured

+ +

Unit Files

+

+ Vitest's V8 coverage provider tracks which source files have at least one + statement executed during unit tests. This counts all .ts files + in the package's src/ directory โ€” a file-level metric that + shows how much of the internal implementation is tested, not just public exports. +

+ +

Unit Exports

+

+ Each public export (function, class, constant) is matched to its source file. + If V8 confirms the source file was executed during unit tests, the export is + marked covered (runtime). If no V8 data exists, we fall back + to static import tracing โ€” checking whether any .test.ts file + imports the export by name (static). +

+ +

E2E %

+

+ Playwright's built-in page.coverage API uses Chrome DevTools + Protocol to collect V8 function-level coverage from each e2e test. No + instrumented builds or Vite plugins are needed โ€” the browser itself tracks + which functions executed at runtime. +

+

+ An export is marked e2e-covered when its source file has + more than one function invoked during any e2e test. This + filters out modules that are merely loaded by the bundler + (module wrapper execution) without any of their functions being actually called. +

+

+ Limitation: For class-based exports, V8 reports constructor + calls in the calling module's scope, not the defining module. This + means callback classes instantiated via a factory pattern may show as uncovered + even if they were used during the test. Top-level function exports + (journey(), oidc(), protect()) have the + highest accuracy. +

+ +

Uncovered

+

+ Exports with neither unit nor e2e coverage from any source. Type-only exports + are excluded from all runtime metrics. +

+
+ {:else if route.view === 'package'} + + {:else if route.view === 'module'} + + {/if} +
+ + diff --git a/tools/coverage-matrix/src/lib/CoverageBadge.svelte b/tools/coverage-matrix/src/lib/CoverageBadge.svelte new file mode 100644 index 0000000000..e5214a0a4d --- /dev/null +++ b/tools/coverage-matrix/src/lib/CoverageBadge.svelte @@ -0,0 +1,27 @@ + + + + {percentage}% + + + diff --git a/tools/coverage-matrix/src/lib/CoverageBar.svelte b/tools/coverage-matrix/src/lib/CoverageBar.svelte new file mode 100644 index 0000000000..a8df80b819 --- /dev/null +++ b/tools/coverage-matrix/src/lib/CoverageBar.svelte @@ -0,0 +1,53 @@ + + +
+ {#if label} + {label} + {/if} +
+
+
+ {covered}/{total} +
+ + diff --git a/tools/coverage-matrix/src/lib/ExportDetail.svelte b/tools/coverage-matrix/src/lib/ExportDetail.svelte new file mode 100644 index 0000000000..588e61dea1 --- /dev/null +++ b/tools/coverage-matrix/src/lib/ExportDetail.svelte @@ -0,0 +1,307 @@ + + +{#if !pkg} +

Package not found: {packageId}

+{:else if !mod} +

Module not found: {moduleName}

+{:else} + + +
+

{mod.name}

+

{mod.path}

+

{mod.exports.length} exports

+
+ +
+

Exports

+
+ {#each mod.exports as exp (exp.name)} +
+
+ {exp.name} + {exp.kind} +
+ + {#if isType(exp)} +

Type exports are not tracked for coverage.

+ {:else} +
+
+ Unit + + {coverageLabel(exp.coverage.unit)} + {#if exp.coverage.unit?.covered} + {exp.coverage.unit.source} + {/if} + + {#if exp.coverage.unit?.testFiles && exp.coverage.unit.testFiles.length > 0} +
+

Test files:

+
    + {#each exp.coverage.unit.testFiles as file (file)} +
  • {file}
  • + {/each} +
+
+ {/if} + {#if exp.coverage.unit?.testSuites && exp.coverage.unit.testSuites.length > 0} +
+

Suites:

+
    + {#each exp.coverage.unit.testSuites as suite (suite)} +
  • {suite}
  • + {/each} +
+
+ {/if} +
+ +
+ E2E + + {coverageLabel(exp.coverage.e2e)} + {#if exp.coverage.e2e?.covered} + {exp.coverage.e2e.source} + {/if} + + {#if exp.coverage.e2e?.testFiles && exp.coverage.e2e.testFiles.length > 0} +
+

Test files:

+
    + {#each exp.coverage.e2e.testFiles as file (file)} +
  • {file}
  • + {/each} +
+
+ {/if} + {#if exp.coverage.e2e?.testSuites && exp.coverage.e2e.testSuites.length > 0} +
+

Suites:

+
    + {#each exp.coverage.e2e.testSuites as suite (suite)} +
  • {suite}
  • + {/each} +
+
+ {/if} +
+
+ {/if} +
+ {/each} +
+
+{/if} + + diff --git a/tools/coverage-matrix/src/lib/Overview.svelte b/tools/coverage-matrix/src/lib/Overview.svelte new file mode 100644 index 0000000000..ae5d27530e --- /dev/null +++ b/tools/coverage-matrix/src/lib/Overview.svelte @@ -0,0 +1,169 @@ + + +
+

Packages

+ + + + + + + + + + + + + {#each sorted as pkg (pkg.path)} + + + + + + + + + {/each} + +
+ + + + + + + + + + + +
+ {pkg.name} + {pkg.modules.length} + + {pkg.summary.unitTestedFiles}/{pkg.summary.totalSourceFiles} + 0}>{pkg.summary.uncovered}
+
+ + diff --git a/tools/coverage-matrix/src/lib/PackageDetail.svelte b/tools/coverage-matrix/src/lib/PackageDetail.svelte new file mode 100644 index 0000000000..4a4e81f2c0 --- /dev/null +++ b/tools/coverage-matrix/src/lib/PackageDetail.svelte @@ -0,0 +1,317 @@ + + +{#if !pkg} +

Package not found: {packageId}

+{:else} + + +
+

{pkg.name}

+

{pkg.path}

+ +
+ + +
+ +
+
+
Total Exports
+
{pkg.summary.totalExports}
+
+
+
Unit Covered
+
{pkg.summary.unitCovered}
+
+
+
E2E Covered
+
{pkg.summary.e2eCovered}
+
+
+
Uncovered
+
0}>{pkg.summary.uncovered}
+
+
+
+ +
+

Modules

+ {#each pkg.modules as mod (mod.name)} +
+ + {mod.name} + {mod.path} + {mod.exports.length} exports + + + + + + + + + + + + + + {#each mod.exports as exp (exp.name)} + + + + {#if isType(exp)} + + + + {:else} + + + + {/if} + + {/each} + +
ExportKindUnitE2EType Test
+ {#if !isType(exp)} + {exp.name} + {:else} + {exp.name} + {/if} + {exp.kind}โ€“โ€“ + {coverageIcon(exp.coverage.typeTest ?? null)} + + {coverageIcon(exp.coverage.unit ?? null)} + {#if sourceLabel(exp.coverage.unit ?? null)} + {sourceLabel(exp.coverage.unit ?? null)} + {/if} + + {coverageIcon(exp.coverage.e2e ?? null)} + {#if sourceLabel(exp.coverage.e2e ?? null)} + {sourceLabel(exp.coverage.e2e ?? null)} + {/if} + โ€“
+
+ {/each} +
+{/if} + + diff --git a/tools/coverage-matrix/src/lib/router.ts b/tools/coverage-matrix/src/lib/router.ts new file mode 100644 index 0000000000..044b3d4f37 --- /dev/null +++ b/tools/coverage-matrix/src/lib/router.ts @@ -0,0 +1,36 @@ +export type Route = + | { view: 'overview' } + | { view: 'package'; packageId: string } + | { view: 'module'; packageId: string; moduleName: string }; + +export function parseHash(hash: string): Route { + const path = hash.replace(/^#\/?/, ''); + if (!path) return { view: 'overview' }; + + const segments = path.split('/').filter(Boolean); + + if (segments[0] === 'package' && segments.length === 2) { + return { view: 'package', packageId: decodeURIComponent(segments[1]) }; + } + + if (segments[0] === 'package' && segments.length >= 3) { + return { + view: 'module', + packageId: decodeURIComponent(segments[1]), + moduleName: decodeURIComponent(segments.slice(2).join('/')), + }; + } + + return { view: 'overview' }; +} + +export function buildHref(route: Route): string { + switch (route.view) { + case 'overview': + return '#/'; + case 'package': + return `#/package/${encodeURIComponent(route.packageId)}`; + case 'module': + return `#/package/${encodeURIComponent(route.packageId)}/${encodeURIComponent(route.moduleName)}`; + } +} diff --git a/tools/coverage-matrix/src/lib/types.ts b/tools/coverage-matrix/src/lib/types.ts new file mode 100644 index 0000000000..f09001cba3 --- /dev/null +++ b/tools/coverage-matrix/src/lib/types.ts @@ -0,0 +1,10 @@ +export type { + CoverageMatrix, + PackageEntry, + ModuleEntry, + ExportEntry, + CoverageEntry, + PackageSummary, + ExportKind, + CoverageSource, +} from '../../scripts/lib/types.js'; diff --git a/tools/coverage-matrix/src/main.ts b/tools/coverage-matrix/src/main.ts new file mode 100644 index 0000000000..deb60b03e2 --- /dev/null +++ b/tools/coverage-matrix/src/main.ts @@ -0,0 +1,7 @@ +import App from './App.svelte'; +import './styles/global.css'; +import { mount } from 'svelte'; + +const app = mount(App, { target: document.getElementById('app')! }); + +export default app; diff --git a/tools/coverage-matrix/src/styles/global.css b/tools/coverage-matrix/src/styles/global.css new file mode 100644 index 0000000000..a4049b27fd --- /dev/null +++ b/tools/coverage-matrix/src/styles/global.css @@ -0,0 +1,59 @@ +:root { + --color-covered: #22c55e; + --color-partial: #eab308; + --color-uncovered: #ef4444; + --color-type: #94a3b8; + --color-bg: #0f172a; + --color-surface: #1e293b; + --color-text: #f8fafc; + --color-text-muted: #94a3b8; + --color-border: #334155; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.6; +} + +main { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +th { + color: var(--color-text-muted); + font-weight: 600; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +a { + color: #60a5fa; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} diff --git a/tools/coverage-matrix/svelte.config.js b/tools/coverage-matrix/svelte.config.js new file mode 100644 index 0000000000..21b9399d42 --- /dev/null +++ b/tools/coverage-matrix/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess() +}; diff --git a/tools/coverage-matrix/tsconfig.json b/tools/coverage-matrix/tsconfig.json new file mode 100644 index 0000000000..32ed609056 --- /dev/null +++ b/tools/coverage-matrix/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ] +} diff --git a/tools/coverage-matrix/tsconfig.lib.json b/tools/coverage-matrix/tsconfig.lib.json new file mode 100644 index 0000000000..890d189287 --- /dev/null +++ b/tools/coverage-matrix/tsconfig.lib.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "ES2022", + "outDir": "dist", + "strict": true, + "types": ["node"], + "rootDir": "." + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["**/*.test.ts", "**/*.spec.ts"] +} diff --git a/tools/coverage-matrix/tsconfig.spec.json b/tools/coverage-matrix/tsconfig.spec.json new file mode 100644 index 0000000000..c718d3c8fb --- /dev/null +++ b/tools/coverage-matrix/tsconfig.spec.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["scripts/__tests__/**/*.ts", "scripts/lib/**/*.ts"] +} diff --git a/tools/coverage-matrix/vite.config.ts b/tools/coverage-matrix/vite.config.ts new file mode 100644 index 0000000000..5468817c35 --- /dev/null +++ b/tools/coverage-matrix/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + root: __dirname, + base: './', + plugins: [svelte()], + build: { + outDir: './dist', + target: 'esnext', + }, + server: { + port: 4200, + }, + test: { + watch: false, + globals: true, + environment: 'node', + include: ['scripts/__tests__/**/*.{test,spec}.ts'], + reporters: ['default'], + coverage: { + include: ['scripts/lib/**/*.ts'], + reporter: ['text', 'html', 'json'], + enabled: Boolean(process.env['CI']), + reportsDirectory: './coverage', + provider: 'v8' as const, + }, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 92f092123e..f3a8c5a018 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -78,6 +78,12 @@ }, { "path": "./e2e/am-mock-api" + }, + { + "path": "./tools/coverage-matrix" + }, + { + "path": "./e2e/shared" } ] }