From 0e53a0d2b18a5dc8475a25145a1bf13b2361f718 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 24 Mar 2026 09:36:16 -0600 Subject: [PATCH 1/9] feat(coverage-matrix): add SDK coverage matrix dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a coverage matrix tool that automatically discovers all @forgerock/* package exports and measures test coverage across unit tests, type tests, and e2e tests โ€” with zero manual path maintenance. Pipeline (all via Nx): pnpm nx run @forgerock/coverage-matrix:coverage-report - Discovers packages from workspace package.json exports fields - Extracts public exports via TypeScript AST - Collects V8 unit coverage from Vitest - Collects V8 e2e coverage from Playwright's built-in CDP API - Merges into a per-export coverage matrix - Builds a Svelte dashboard deployable to GitHub Pages E2E coverage uses Playwright's page.coverage.startJSCoverage() โ€” no instrumented builds, no Vite plugins, no Nx cache concerns. The @forgerock/e2e-shared package provides a coverage fixture that all 26 e2e test files import for automatic collection. CI integration: - Non-blocking coverage-report step in PR workflow - Coverage summary posted as PR comment - Dashboard deployed to GitHub Pages on merge to main --- .github/workflows/ci.yml | 54 +++ .gitignore | 1 + e2e/davinci-suites/package.json | 3 + e2e/davinci-suites/src/basic.test.ts | 2 +- e2e/davinci-suites/src/fido.test.ts | 2 +- e2e/davinci-suites/src/form-fields.test.ts | 2 +- e2e/davinci-suites/src/logging.test.ts | 2 +- e2e/davinci-suites/src/mfa.test.ts | 2 +- e2e/davinci-suites/src/middleware.test.ts | 2 +- .../src/phone-number-field.test.ts | 2 +- e2e/davinci-suites/src/protect.test.ts | 2 +- e2e/davinci-suites/src/register.test.ts | 2 +- e2e/davinci-suites/tsconfig.json | 3 + e2e/device-client-app/vite.config.ts | 5 +- e2e/journey-suites/package.json | 3 + .../src/choice-confirm-poll.test.ts | 2 +- e2e/journey-suites/src/custom-paths.test.ts | 2 +- e2e/journey-suites/src/device-profile.test.ts | 2 +- e2e/journey-suites/src/email-suspend.test.ts | 2 +- e2e/journey-suites/src/login.test.ts | 2 +- e2e/journey-suites/src/no-session.test.ts | 2 +- e2e/journey-suites/src/otp-register.test.ts | 2 +- e2e/journey-suites/src/protect.test.ts | 2 +- e2e/journey-suites/src/qr-code.test.ts | 2 +- e2e/journey-suites/src/recovery-codes.test.ts | 2 +- e2e/journey-suites/src/registration.test.ts | 2 +- .../src/request-middleware.test.ts | 2 +- e2e/journey-suites/tsconfig.json | 3 + e2e/oidc-app/vite.config.ts | 6 +- e2e/oidc-suites/package.json | 4 + e2e/oidc-suites/src/login.spec.ts | 2 +- e2e/oidc-suites/src/logout.spec.ts | 2 +- e2e/oidc-suites/src/token.spec.ts | 2 +- e2e/oidc-suites/src/user.spec.ts | 2 +- e2e/oidc-suites/tsconfig.json | 5 + e2e/protect-suites/package.json | 3 + e2e/protect-suites/src/protect-native.test.ts | 2 +- e2e/protect-suites/tsconfig.json | 3 + e2e/shared/coverage-fixture.ts | 51 +++ e2e/shared/package.json | 12 + e2e/shared/tsconfig.json | 10 + eslint.config.mjs | 17 + package.json | 1 - pnpm-lock.yaml | 366 +++++++++++++++--- tools/coverage-matrix/.gitignore | 1 + tools/coverage-matrix/data/.gitkeep | 0 tools/coverage-matrix/eslint.config.mjs | 3 + tools/coverage-matrix/index.html | 12 + tools/coverage-matrix/package.json | 24 ++ tools/coverage-matrix/project.json | 35 ++ tools/coverage-matrix/public/data | 1 + .../__tests__/collect-e2e-coverage.test.ts | 126 ++++++ .../__tests__/discover-packages.test.ts | 110 ++++++ .../scripts/__tests__/extract-exports.test.ts | 43 ++ .../mock-package-conditional/package.json | 22 ++ .../mock-package-simple/eslint.config.js | 2 + .../fixtures/mock-package-simple/package.json | 10 + .../fixtures/mock-package-simple/src/index.ts | 2 + .../mock-package-simple/src/lib/foo.test.ts | 7 + .../mock-package-simple/src/lib/foo.ts | 17 + .../scripts/__tests__/map-e2e-suites.test.ts | 33 ++ .../scripts/__tests__/merge.test.ts | 206 ++++++++++ .../__tests__/trace-unit-tests.test.ts | 43 ++ tools/coverage-matrix/scripts/analyze.ts | 117 ++++++ tools/coverage-matrix/scripts/collect.ts | 39 ++ tools/coverage-matrix/scripts/generate.ts | 50 +++ .../scripts/lib/collect-e2e-coverage.ts | 95 +++++ .../scripts/lib/collect-unit-coverage.ts | 58 +++ .../scripts/lib/discover-packages.ts | 136 +++++++ .../scripts/lib/extract-exports.ts | 84 ++++ .../scripts/lib/map-e2e-suites.ts | 111 ++++++ tools/coverage-matrix/scripts/lib/merge.ts | 307 +++++++++++++++ .../scripts/lib/trace-unit-tests.ts | 92 +++++ tools/coverage-matrix/scripts/lib/types.ts | 123 ++++++ tools/coverage-matrix/src/App.svelte | 154 ++++++++ .../src/lib/CoverageBadge.svelte | 27 ++ .../src/lib/CoverageBar.svelte | 53 +++ .../src/lib/ExportDetail.svelte | 307 +++++++++++++++ tools/coverage-matrix/src/lib/Overview.svelte | 169 ++++++++ .../src/lib/PackageDetail.svelte | 317 +++++++++++++++ tools/coverage-matrix/src/lib/router.ts | 36 ++ tools/coverage-matrix/src/lib/types.ts | 10 + tools/coverage-matrix/src/main.ts | 7 + tools/coverage-matrix/src/styles/global.css | 59 +++ tools/coverage-matrix/svelte.config.js | 5 + tools/coverage-matrix/tsconfig.json | 8 + tools/coverage-matrix/tsconfig.lib.json | 14 + tools/coverage-matrix/tsconfig.spec.json | 7 + tools/coverage-matrix/vite.config.ts | 29 ++ tsconfig.json | 6 + 90 files changed, 3623 insertions(+), 96 deletions(-) create mode 100644 e2e/shared/coverage-fixture.ts create mode 100644 e2e/shared/package.json create mode 100644 e2e/shared/tsconfig.json create mode 100644 tools/coverage-matrix/.gitignore create mode 100644 tools/coverage-matrix/data/.gitkeep create mode 100644 tools/coverage-matrix/eslint.config.mjs create mode 100644 tools/coverage-matrix/index.html create mode 100644 tools/coverage-matrix/package.json create mode 100644 tools/coverage-matrix/project.json create mode 120000 tools/coverage-matrix/public/data create mode 100644 tools/coverage-matrix/scripts/__tests__/collect-e2e-coverage.test.ts create mode 100644 tools/coverage-matrix/scripts/__tests__/discover-packages.test.ts create mode 100644 tools/coverage-matrix/scripts/__tests__/extract-exports.test.ts create mode 100644 tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-conditional/package.json create mode 100644 tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/eslint.config.js create mode 100644 tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/package.json create mode 100644 tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/index.ts create mode 100644 tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/lib/foo.test.ts create mode 100644 tools/coverage-matrix/scripts/__tests__/fixtures/mock-package-simple/src/lib/foo.ts create mode 100644 tools/coverage-matrix/scripts/__tests__/map-e2e-suites.test.ts create mode 100644 tools/coverage-matrix/scripts/__tests__/merge.test.ts create mode 100644 tools/coverage-matrix/scripts/__tests__/trace-unit-tests.test.ts create mode 100644 tools/coverage-matrix/scripts/analyze.ts create mode 100644 tools/coverage-matrix/scripts/collect.ts create mode 100644 tools/coverage-matrix/scripts/generate.ts create mode 100644 tools/coverage-matrix/scripts/lib/collect-e2e-coverage.ts create mode 100644 tools/coverage-matrix/scripts/lib/collect-unit-coverage.ts create mode 100644 tools/coverage-matrix/scripts/lib/discover-packages.ts create mode 100644 tools/coverage-matrix/scripts/lib/extract-exports.ts create mode 100644 tools/coverage-matrix/scripts/lib/map-e2e-suites.ts create mode 100644 tools/coverage-matrix/scripts/lib/merge.ts create mode 100644 tools/coverage-matrix/scripts/lib/trace-unit-tests.ts create mode 100644 tools/coverage-matrix/scripts/lib/types.ts create mode 100644 tools/coverage-matrix/src/App.svelte create mode 100644 tools/coverage-matrix/src/lib/CoverageBadge.svelte create mode 100644 tools/coverage-matrix/src/lib/CoverageBar.svelte create mode 100644 tools/coverage-matrix/src/lib/ExportDetail.svelte create mode 100644 tools/coverage-matrix/src/lib/Overview.svelte create mode 100644 tools/coverage-matrix/src/lib/PackageDetail.svelte create mode 100644 tools/coverage-matrix/src/lib/router.ts create mode 100644 tools/coverage-matrix/src/lib/types.ts create mode 100644 tools/coverage-matrix/src/main.ts create mode 100644 tools/coverage-matrix/src/styles/global.css create mode 100644 tools/coverage-matrix/svelte.config.js create mode 100644 tools/coverage-matrix/tsconfig.json create mode 100644 tools/coverage-matrix/tsconfig.lib.json create mode 100644 tools/coverage-matrix/tsconfig.spec.json create mode 100644 tools/coverage-matrix/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a982d7c055..bd6ced2173 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,3 +134,57 @@ 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< + ## Coverage Matrix + + ${{ steps.coverage-matrix-summary.outputs.table }} + + [Full dashboard](https://forgerock.github.io/ping-javascript-sdk/coverage-matrix/) + + - name: Deploy dashboard to GitHub Pages + continue-on-error: true + if: github.ref == 'refs/heads/main' + 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 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/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..c33067aa92 --- /dev/null +++ b/e2e/shared/coverage-fixture.ts @@ -0,0 +1,51 @@ +/** + * 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'; + +const COVERAGE_DIR = join(import.meta.dirname, '..', '..', '.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/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..8e79503a93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,46 +5,15 @@ settings: excludeLinksFromLockfile: false catalogs: - default: - '@reduxjs/toolkit': - specifier: ^2.8.2 - version: 2.10.1 - immer: - specifier: ^10.1.1 - version: 10.2.0 - msw: - specifier: ^2.5.1 - version: 2.12.1 effect: '@effect/cli': specifier: ^0.69.0 version: 0.69.2 - '@effect/language-service': - specifier: ^0.35.2 - version: 0.35.2 - '@effect/opentelemetry': - specifier: ^0.56.1 - version: 0.56.6 - '@effect/platform': - specifier: ^0.90.0 - version: 0.90.10 - '@effect/platform-node': - specifier: 0.94.2 - version: 0.94.2 - '@effect/vitest': - specifier: ^0.27.0 - version: 0.27.0 - effect: - specifier: ^3.19.0 - version: 3.19.3 vite: vite: specifier: ^7.3.1 version: 7.3.1 vitest: - '@vitest/coverage-v8': - specifier: ^3.2.0 - version: 3.2.4 vitest: specifier: ^3.2.0 version: 3.2.4 @@ -308,7 +277,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 +314,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 +369,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 +384,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 +565,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': @@ -749,6 +758,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} engines: {node: '>=6.9.0'} @@ -1251,6 +1265,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -2004,6 +2022,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 +2900,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 +3176,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==} @@ -3589,6 +3634,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + address@1.2.2: resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} engines: {node: '>= 10.0.0'} @@ -3682,6 +3732,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 +3822,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 +3895,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 +3959,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 +4147,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 +4595,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 +4895,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 +4915,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 +5330,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 +5748,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 +5883,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 +6107,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 +6146,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 +6398,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 +6818,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 +7624,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 +8133,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 +8366,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'} @@ -8328,7 +8451,7 @@ snapshots: '@babel/generator@7.28.5': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@babel/types': 7.28.5 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -8336,7 +8459,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -8382,7 +8505,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -8404,7 +8527,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/helper-plugin-utils@7.27.1': {} @@ -8429,7 +8552,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -8443,7 +8566,7 @@ snapshots: dependencies: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -8456,6 +8579,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -9060,7 +9187,7 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@babel/types': 7.28.5 '@babel/traverse@7.28.5': @@ -9068,7 +9195,7 @@ snapshots: '@babel/code-frame': 7.27.1 '@babel/generator': 7.28.5 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@babel/template': 7.27.2 '@babel/types': 7.28.5 debug: 4.4.3 @@ -9080,6 +9207,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} '@bcoe/v8-coverage@1.0.2': {} @@ -9820,6 +9952,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 +10997,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) @@ -11014,8 +11174,8 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -11026,12 +11186,12 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@types/body-parser@1.19.6': dependencies: @@ -11137,6 +11297,8 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': {} + '@types/unist@3.0.3': {} '@types/yargs-parser@21.0.3': {} @@ -11495,7 +11657,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -11554,7 +11716,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) '@vitest/utils@3.2.4': dependencies: @@ -11564,7 +11726,7 @@ snapshots: '@vue/compiler-core@3.5.24': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@vue/shared': 3.5.24 entities: 4.5.0 estree-walker: 2.0.2 @@ -11577,7 +11739,7 @@ snapshots: '@vue/compiler-sfc@3.5.24': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@vue/compiler-core': 3.5.24 '@vue/compiler-dom': 3.5.24 '@vue/compiler-ssr': 3.5.24 @@ -11799,9 +11961,9 @@ snapshots: mime-types: 3.0.1 negotiator: 1.0.0 - acorn-import-phases@1.0.4(acorn@8.15.0): + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -11813,6 +11975,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: {} + address@1.2.2: {} agent-base@6.0.2: @@ -11893,6 +12057,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 +12159,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 +12263,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bare-events@2.8.2: {} base64-js@1.5.1: {} @@ -12178,6 +12348,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 +12523,8 @@ snapshots: clone@1.0.4: {} + clsx@2.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -12763,6 +12939,8 @@ snapshots: transitivePeerDependencies: - supports-color + devalue@5.6.4: {} + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -13181,6 +13359,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm-env@1.2.2: {} + espree@10.4.0: dependencies: acorn: 8.15.0 @@ -13199,6 +13379,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 +13891,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 +14349,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 @@ -14249,7 +14447,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.7.3 @@ -14281,6 +14479,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 @@ -14497,7 +14699,7 @@ snapshots: '@babel/generator': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@jest/expect-utils': 30.2.0 '@jest/get-type': 30.1.0 '@jest/snapshot-utils': 30.2.0 @@ -14693,6 +14895,8 @@ snapshots: kind-of@6.0.3: {} + kleur@4.1.5: {} + leven@3.1.0: {} levn@0.4.1: @@ -14736,6 +14940,8 @@ snapshots: loader-runner@4.3.1: {} + locate-character@3.0.0: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -14854,7 +15060,7 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@babel/types': 7.28.5 source-map-js: 1.2.1 @@ -14940,6 +15146,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 @@ -15117,7 +15327,7 @@ snapshots: node-source-walk@7.0.1: dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 normalize-path@3.0.0: {} @@ -15407,6 +15617,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 +16546,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) @@ -16385,7 +16619,7 @@ snapshots: terser@5.46.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -16879,11 +17113,15 @@ 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 moo-color: 1.0.3 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: @@ -17004,8 +17242,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.27.0 chrome-trace-event: 1.0.4 enhanced-resolve: 5.19.0 @@ -17182,6 +17420,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..e81a03e94f --- /dev/null +++ b/tools/coverage-matrix/scripts/__tests__/collect-e2e-coverage.test.ts @@ -0,0 +1,126 @@ +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 coverageDir = join(tmpDir, '.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..c2170f6177 --- /dev/null +++ b/tools/coverage-matrix/scripts/lib/collect-e2e-coverage.ts @@ -0,0 +1,95 @@ +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. + */ +export function collectE2eCoverage(workspaceRoot: string): RuntimeE2eCoverage | null { + const coverageDir = join(workspaceRoot, '.e2e-coverage'); + + if (!existsSync(coverageDir)) return null; + + const files = readdirSync(coverageDir).filter((f) => f.endsWith('.json')); + if (files.length === 0) return null; + + const allCoveredFiles = new Set(); + + 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..6ecfb9f453 --- /dev/null +++ b/tools/coverage-matrix/scripts/lib/merge.ts @@ -0,0 +1,307 @@ +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'); +} + +/** Build a flat Set of all source files covered by e2e tests (V8/Playwright data) */ +function buildRuntimeE2eFileSet(runtimeCoverage: RuntimeCoverage | null): Set { + const files = new Set(); + if (!runtimeCoverage) return files; + + for (const entry of runtimeCoverage.e2eCoverage) { + for (const file of entry.coveredFiles) { + files.add(normalizeDistPath(file)); + } + } + 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, +): 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 Istanbul only, no static fallback. + // Only count exports whose source file had functions actually invoked. + let e2eCoverage: CoverageEntry | null = null; + if (!isType && runtimeE2eFiles.has(exportDef.sourceFile)) { + 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, +): ModuleEntry { + const exports = moduleDef.exports.map((exp) => + buildExportEntry(exp, unitMap, typeTestMap, e2eExportMap, runtimeUnitFiles, runtimeE2eFiles), + ); + 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, + ), + ); + + // 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); + + 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..fd489a18e9 --- /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: '/ping-javascript-sdk/coverage-matrix/', + 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" } ] } From 9dff46705a2682d8ac3e06d529bf496b2fdc6e65 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 24 Mar 2026 10:22:52 -0600 Subject: [PATCH 2/9] fix: restore missing pnpm catalog entries in lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lockfile was regenerated in a way that dropped catalog definitions for default, effect, and vitest catalogs โ€” causing CI failures for all packages referencing catalog: specifiers under --frozen-lockfile. --- pnpm-lock.yaml | 110 ++++++++++++++++++++++++++----------------------- 1 file changed, 58 insertions(+), 52 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e79503a93..656456ec96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,15 +5,46 @@ settings: excludeLinksFromLockfile: false catalogs: + default: + '@reduxjs/toolkit': + specifier: ^2.8.2 + version: 2.10.1 + immer: + specifier: ^10.1.1 + version: 10.2.0 + msw: + specifier: ^2.5.1 + version: 2.12.1 effect: '@effect/cli': specifier: ^0.69.0 version: 0.69.2 + '@effect/language-service': + specifier: ^0.35.2 + version: 0.35.2 + '@effect/opentelemetry': + specifier: ^0.56.1 + version: 0.56.6 + '@effect/platform': + specifier: ^0.90.0 + version: 0.90.10 + '@effect/platform-node': + specifier: 0.94.2 + version: 0.94.2 + '@effect/vitest': + specifier: ^0.27.0 + version: 0.27.0 + effect: + specifier: ^3.19.0 + version: 3.19.3 vite: vite: specifier: ^7.3.1 version: 7.3.1 vitest: + '@vitest/coverage-v8': + specifier: ^3.2.0 + version: 3.2.4 vitest: specifier: ^3.2.0 version: 3.2.4 @@ -758,11 +789,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} engines: {node: '>=6.9.0'} @@ -1265,10 +1291,6 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -3634,11 +3656,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - address@1.2.2: resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} engines: {node: '>= 10.0.0'} @@ -8451,7 +8468,7 @@ snapshots: '@babel/generator@7.28.5': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.28.5 '@babel/types': 7.28.5 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -8459,7 +8476,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.28.5 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -8505,7 +8522,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.29.0 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -8527,7 +8544,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.28.5 '@babel/helper-plugin-utils@7.27.1': {} @@ -8552,7 +8569,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.29.0 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -8566,7 +8583,7 @@ snapshots: dependencies: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 - '@babel/types': 7.29.0 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -8579,10 +8596,6 @@ snapshots: dependencies: '@babel/types': 7.28.5 - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -9187,7 +9200,7 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.29.2 + '@babel/parser': 7.28.5 '@babel/types': 7.28.5 '@babel/traverse@7.28.5': @@ -9195,7 +9208,7 @@ snapshots: '@babel/code-frame': 7.27.1 '@babel/generator': 7.28.5 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 debug: 4.4.3 @@ -9207,11 +9220,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@bcoe/v8-coverage@0.2.3': {} '@bcoe/v8-coverage@1.0.2': {} @@ -11174,8 +11182,8 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -11186,12 +11194,12 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.28.5 '@types/body-parser@1.19.6': dependencies: @@ -11657,7 +11665,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -11716,7 +11724,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) '@vitest/utils@3.2.4': dependencies: @@ -11726,7 +11734,7 @@ snapshots: '@vue/compiler-core@3.5.24': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.28.5 '@vue/shared': 3.5.24 entities: 4.5.0 estree-walker: 2.0.2 @@ -11739,7 +11747,7 @@ snapshots: '@vue/compiler-sfc@3.5.24': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.28.5 '@vue/compiler-core': 3.5.24 '@vue/compiler-dom': 3.5.24 '@vue/compiler-ssr': 3.5.24 @@ -11961,9 +11969,9 @@ snapshots: mime-types: 3.0.1 negotiator: 1.0.0 - acorn-import-phases@1.0.4(acorn@8.16.0): + acorn-import-phases@1.0.4(acorn@8.15.0): dependencies: - acorn: 8.16.0 + acorn: 8.15.0 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -11975,8 +11983,6 @@ snapshots: acorn@8.15.0: {} - acorn@8.16.0: {} - address@1.2.2: {} agent-base@6.0.2: @@ -14447,7 +14453,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.28.5 - '@babel/parser': 7.29.2 + '@babel/parser': 7.28.5 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.7.3 @@ -14699,7 +14705,7 @@ snapshots: '@babel/generator': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) - '@babel/types': 7.29.0 + '@babel/types': 7.28.5 '@jest/expect-utils': 30.2.0 '@jest/get-type': 30.1.0 '@jest/snapshot-utils': 30.2.0 @@ -15060,7 +15066,7 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.28.5 '@babel/types': 7.28.5 source-map-js: 1.2.1 @@ -15327,7 +15333,7 @@ snapshots: node-source-walk@7.0.1: dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.28.5 normalize-path@3.0.0: {} @@ -16619,7 +16625,7 @@ snapshots: terser@5.46.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -17121,7 +17127,7 @@ snapshots: dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: @@ -17242,8 +17248,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.16.0 - acorn-import-phases: 1.0.4(acorn@8.16.0) + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) browserslist: 4.27.0 chrome-trace-event: 1.0.4 enhanced-resolve: 5.19.0 From 36036b3aeed6270e84dab493fd6631297c71d42d Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 24 Mar 2026 10:30:36 -0600 Subject: [PATCH 3/9] fix: exclude test fixtures from Nx project graph Nx discovers package.json files under tools/coverage-matrix test fixtures and fails because they aren't valid workspace projects. Adding .nxignore to exclude the fixtures directory. --- .nxignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nxignore 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 From 94f3f753087ca0867d742c154f2cd33c6a9d5dcd Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 24 Mar 2026 10:49:32 -0600 Subject: [PATCH 4/9] fix: move dashboard deploy to publish workflow, note E2E CI limitation The deploy step was in ci.yml (PR-only) with a condition that could never be true. Move it to publish.yml which runs on push to main. Add note to PR comment that E2E coverage data is not available in PR CI since e2e tests run on DTE agents without V8 instrumentation. --- .github/workflows/ci.yml | 12 ++---------- .github/workflows/publish.yml | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd6ced2173..cc885b06f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,14 +177,6 @@ jobs: ${{ steps.coverage-matrix-summary.outputs.table }} - [Full dashboard](https://forgerock.github.io/ping-javascript-sdk/coverage-matrix/) + > **Note:** E2E coverage requires V8 coverage data from instrumented test runs and is not collected during PR CI. - - name: Deploy dashboard to GitHub Pages - continue-on-error: true - if: github.ref == 'refs/heads/main' - 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 + [Full dashboard](https://forgerock.github.io/ping-javascript-sdk/coverage-matrix/) 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 From 970f9baa3fea18a6b7a6a18f4b931b00c4628e04 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 24 Mar 2026 10:55:11 -0600 Subject: [PATCH 5/9] fix: write e2e coverage to project dirs for DTE artifact transfer Coverage fixture now writes to {cwd}/.e2e-coverage/ (the suite's project dir) instead of the workspace root. This allows Nx Cloud DTE agents to transfer coverage data back as declared task outputs. Updated e2e-ci target outputs to include .e2e-coverage, and changed the collector to scan all e2e/*/.e2e-coverage/ directories. --- e2e/shared/coverage-fixture.ts | 9 +++- nx.json | 4 +- .../__tests__/collect-e2e-coverage.test.ts | 3 +- .../scripts/lib/collect-e2e-coverage.ts | 54 +++++++++++++------ 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/e2e/shared/coverage-fixture.ts b/e2e/shared/coverage-fixture.ts index c33067aa92..c57ab8218d 100644 --- a/e2e/shared/coverage-fixture.ts +++ b/e2e/shared/coverage-fixture.ts @@ -17,7 +17,14 @@ import { writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; -const COVERAGE_DIR = join(import.meta.dirname, '..', '..', '.e2e-coverage'); +/** + * 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) => { 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/tools/coverage-matrix/scripts/__tests__/collect-e2e-coverage.test.ts b/tools/coverage-matrix/scripts/__tests__/collect-e2e-coverage.test.ts index e81a03e94f..2526854b6b 100644 --- a/tools/coverage-matrix/scripts/__tests__/collect-e2e-coverage.test.ts +++ b/tools/coverage-matrix/scripts/__tests__/collect-e2e-coverage.test.ts @@ -4,7 +4,8 @@ import { join } from 'node:path'; import { collectE2eCoverage } from '../lib/collect-e2e-coverage.js'; const tmpDir = join(import.meta.dirname, '.tmp-e2e-coverage-test'); -const coverageDir = join(tmpDir, '.e2e-coverage'); +const suiteDir = join(tmpDir, 'e2e', 'test-suite'); +const coverageDir = join(suiteDir, '.e2e-coverage'); function writeV8Coverage(filename: string, entries: unknown[]): void { mkdirSync(coverageDir, { recursive: true }); diff --git a/tools/coverage-matrix/scripts/lib/collect-e2e-coverage.ts b/tools/coverage-matrix/scripts/lib/collect-e2e-coverage.ts index c2170f6177..1329f6ac70 100644 --- a/tools/coverage-matrix/scripts/lib/collect-e2e-coverage.ts +++ b/tools/coverage-matrix/scripts/lib/collect-e2e-coverage.ts @@ -61,31 +61,51 @@ function extractFilePath(url: string): string | null { * Uses Playwright's built-in V8 coverage โ€” no Istanbul instrumentation * required. The browser itself tracks function execution. */ -export function collectE2eCoverage(workspaceRoot: string): RuntimeE2eCoverage | null { - const coverageDir = join(workspaceRoot, '.e2e-coverage'); +/** + * 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 []; - if (!existsSync(coverageDir)) return null; + 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); - const files = readdirSync(coverageDir).filter((f) => f.endsWith('.json')); - if (files.length === 0) return null; + if (coverageDirs.length === 0) return null; const allCoveredFiles = new Set(); - for (const file of files) { - try { - const entries: V8CoverageEntry[] = JSON.parse(readFileSync(join(coverageDir, file), 'utf-8')); + 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); + 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; } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[collect-e2e-coverage] Skipping ${file}: ${message}`); - continue; } } From 2e2d9b9fc01aa3948a77edf2567deb06827b019f Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 24 Mar 2026 11:00:52 -0600 Subject: [PATCH 6/9] chore: remove obsolete E2E coverage note from PR comment E2E coverage data is now transferred back from DTE agents via declared outputs, so the disclaimer is no longer accurate. --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc885b06f2..90742d96f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,4 @@ jobs: ${{ steps.coverage-matrix-summary.outputs.table }} - > **Note:** E2E coverage requires V8 coverage data from instrumented test runs and is not collected during PR CI. - [Full dashboard](https://forgerock.github.io/ping-javascript-sdk/coverage-matrix/) From 2f4fa5ab69efd3832a6850d39d14811946f8a1bc Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 24 Mar 2026 11:11:06 -0600 Subject: [PATCH 7/9] fix: use workspace-relative paths for e2e coverage matching, dedupe PR comment E2e coverage paths from DTE agents are absolute and differ from the main runner. Normalize both sides to workspace-relative paths (packages/...) before comparison. Use find-comment + create-or-update-comment pattern to update a single PR comment instead of creating duplicates on each push. --- .github/workflows/ci.yml | 12 ++++++ tools/coverage-matrix/scripts/lib/merge.ts | 46 +++++++++++++++++++--- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90742d96f4..bf6cd939aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,12 +165,24 @@ jobs: } " + - name: Find coverage matrix comment + continue-on-error: true + if: github.event_name == 'pull_request' + id: find-coverage-comment + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: + - 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 diff --git a/tools/coverage-matrix/scripts/lib/merge.ts b/tools/coverage-matrix/scripts/lib/merge.ts index 6ecfb9f453..8469960809 100644 --- a/tools/coverage-matrix/scripts/lib/merge.ts +++ b/tools/coverage-matrix/scripts/lib/merge.ts @@ -94,14 +94,35 @@ 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): Set { +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(normalizeDistPath(file)); + files.add(toWorkspaceRelative(normalizeDistPath(file), workspaceRoot)); } } return files; @@ -114,6 +135,7 @@ function buildExportEntry( e2eExportMap: Map | undefined, runtimeUnitFiles: Set | undefined, runtimeE2eFiles: Set, + workspaceRoot: string, ): ExportEntry { const isType = exportDef.kind === 'type'; @@ -138,10 +160,12 @@ function buildExportEntry( } } - // E2E coverage โ€” runtime Istanbul only, no static fallback. + // 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; - if (!isType && runtimeE2eFiles.has(exportDef.sourceFile)) { + const relativeSourceFile = toWorkspaceRelative(exportDef.sourceFile, workspaceRoot); + if (!isType && runtimeE2eFiles.has(relativeSourceFile)) { e2eCoverage = { covered: true, source: 'runtime', @@ -183,9 +207,18 @@ function buildModuleEntry( e2eExportMap: Map | undefined, runtimeUnitFiles: Set | undefined, runtimeE2eFiles: Set, + workspaceRoot: string, ): ModuleEntry { const exports = moduleDef.exports.map((exp) => - buildExportEntry(exp, unitMap, typeTestMap, e2eExportMap, runtimeUnitFiles, runtimeE2eFiles), + buildExportEntry( + exp, + unitMap, + typeTestMap, + e2eExportMap, + runtimeUnitFiles, + runtimeE2eFiles, + workspaceRoot, + ), ); return { name: moduleDef.name, @@ -250,6 +283,7 @@ function buildPackageEntry( pkgE2eExportMap, runtimeUnitFiles, runtimeE2eFiles, + workspaceRoot, ), ); @@ -282,7 +316,7 @@ export function buildCoverageMatrix( const typeTestMap = buildTypeTestMap(staticAnalysis); const e2eExportMap = buildE2eExportMap(staticAnalysis); const runtimeUnitMap = buildRuntimeUnitMap(runtimeCoverage); - const runtimeE2eFiles = buildRuntimeE2eFileSet(runtimeCoverage); + const runtimeE2eFiles = buildRuntimeE2eFileSet(runtimeCoverage, workspaceRoot); const hasRuntime = runtimeUnitMap.size > 0 || runtimeE2eFiles.size > 0; const source = hasRuntime ? 'hybrid' : 'static'; From 399e9145d0f6fe7a24fb133d18bab87dcf3ab981 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 24 Mar 2026 11:20:03 -0600 Subject: [PATCH 8/9] fix: deploy coverage dashboard from PR workflow The deploy was only in publish.yml (main branch), so the dashboard was never populated. Add it to ci.yml as well so it deploys on every PR push to keep the dashboard current. --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf6cd939aa..a6d18f9764 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,3 +190,12 @@ jobs: ${{ steps.coverage-matrix-summary.outputs.table }} [Full dashboard](https://forgerock.github.io/ping-javascript-sdk/coverage-matrix/) + + - 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 From 60493b7367537ddeaf2962debe83296a1573e606 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 24 Mar 2026 11:47:49 -0600 Subject: [PATCH 9/9] fix: deploy PR dashboard to preview path, use relative base PR deploys to pr-{number}/{sha}/coverage-matrix/ alongside docs preview. Main deploys to /coverage-matrix/ from publish.yml. Changed Vite base to ./ so the same build works at any URL depth. --- .github/workflows/ci.yml | 6 +++--- tools/coverage-matrix/vite.config.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6d18f9764..a10910a688 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,13 +189,13 @@ jobs: ${{ steps.coverage-matrix-summary.outputs.table }} - [Full dashboard](https://forgerock.github.io/ping-javascript-sdk/coverage-matrix/) + [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: Deploy coverage dashboard to GitHub Pages + - 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: coverage-matrix + destination_dir: pr-${{ github.event.number }}/${{ github.sha }}/coverage-matrix keep_files: true diff --git a/tools/coverage-matrix/vite.config.ts b/tools/coverage-matrix/vite.config.ts index fd489a18e9..5468817c35 100644 --- a/tools/coverage-matrix/vite.config.ts +++ b/tools/coverage-matrix/vite.config.ts @@ -3,7 +3,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'; export default defineConfig({ root: __dirname, - base: '/ping-javascript-sdk/coverage-matrix/', + base: './', plugins: [svelte()], build: { outDir: './dist',