diff --git a/.github/labeler.yml b/.github/labeler.yml index a97e4a26c9..d0117aae31 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -83,6 +83,16 @@ - any-glob-to-any-file: - 'projects/pages/**' +'scope(plot)': + - changed-files: + - any-glob-to-any-file: + - 'projects/plot/**' + +'scope(scene)': + - changed-files: + - any-glob-to-any-file: + - 'projects/scene/**' + 'scope(starters)': - changed-files: - any-glob-to-any-file: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 002e79088f..341139264b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,16 @@ permissions: jobs: ci: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + code-quality: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 lfs: true + ref: ${{ github.event.pull_request.head.sha || github.sha }} # The CLI build runs semantic-release dry-run to bake the next binary version. # On main, checkout credentials must match the release token used by that dry-run. token: ${{ github.ref == 'refs/heads/main' && secrets.RELEASE_TOKEN || github.token }} @@ -32,6 +37,16 @@ jobs: ELEMENTS_REPO_BASE_URL: ${{vars.ELEMENTS_REPO_BASE_URL}} GITHUB_TOKEN: ${{ github.ref == 'refs/heads/main' && secrets.RELEASE_TOKEN || github.token }} run: PAGES_BASE_URL="/elements/" pnpm run ci && PAGES_BASE_URL="/elements/" node ./projects/internals/ci/cache-validate.js ci + - name: Upload code coverage + if: >- + github.event_name == 'push' || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + uses: actions/upload-code-coverage@d8e329117199404bba6fc81efe8093dc7c015e34 # v1.4.2 + with: + file: projects/internals/ci/coverage/cobertura.xml + language: TypeScript + label: code-coverage/vitest + fail-on-error: true - name: Write CI job summary if: always() run: node ./projects/internals/ci/ci-summary.js >> "$GITHUB_STEP_SUMMARY" diff --git a/package.json b/package.json index 62a0e27e12..ac0bfce31f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "@semantic-release/github": "12.0.9", "@semantic-release/npm": "13.1.5", "@semantic-release/release-notes-generator": "14.1.1", + "@vitest/istanbul-lib-coverage": "catalog:", + "@vitest/istanbul-lib-report": "catalog:", "@weiran.zsd/multi-eslint-rule-config": "0.0.6", "commitlint": "21.2.2", "glob": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f91ec94f4..68127ace10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,12 @@ catalogs: '@vitest/coverage-istanbul': specifier: 5.0.0 version: 5.0.0 + '@vitest/istanbul-lib-coverage': + specifier: 1.0.1 + version: 1.0.1 + '@vitest/istanbul-lib-report': + specifier: 1.0.1 + version: 1.0.1 adm-zip: specifier: 0.6.1 version: 0.6.1 @@ -406,6 +412,12 @@ importers: '@semantic-release/release-notes-generator': specifier: 14.1.1 version: 14.1.1(semantic-release@25.0.9(supports-color@10.2.2)(typescript@7.0.2))(supports-color@10.2.2) + '@vitest/istanbul-lib-coverage': + specifier: 'catalog:' + version: 1.0.1 + '@vitest/istanbul-lib-report': + specifier: 'catalog:' + version: 1.0.1 '@weiran.zsd/multi-eslint-rule-config': specifier: 0.0.6 version: 0.0.6(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 36e590be51..a19aff7216 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -100,6 +100,8 @@ catalog: '@vitest/browser': 5.0.0 '@vitest/browser-playwright': 5.0.0 '@vitest/coverage-istanbul': 5.0.0 + '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/istanbul-lib-report': 1.0.1 'adm-zip': 0.6.1 axe-core: 4.11.0 eslint: 10.10.0 diff --git a/projects/code/package.json b/projects/code/package.json index b78b799ef0..28250aa096 100644 --- a/projects/code/package.json +++ b/projects/code/package.json @@ -322,7 +322,9 @@ "vite.config.ts", "vitest.config.ts" ], - "output": [], + "output": [ + "coverage/unit/**" + ], "dependencies": [ "../internals/testing:build", "../internals/vite:ci", diff --git a/projects/forms/package.json b/projects/forms/package.json index 675f807f89..231d5fc8bb 100644 --- a/projects/forms/package.json +++ b/projects/forms/package.json @@ -197,7 +197,9 @@ "vite.config.ts", "vitest.config.ts" ], - "output": [], + "output": [ + "coverage/unit/**" + ], "dependencies": [ "../internals/testing:build", "../internals/vite:ci", diff --git a/projects/internals/ci/README.md b/projects/internals/ci/README.md index ac39744f5d..f989351fa1 100644 --- a/projects/internals/ci/README.md +++ b/projects/internals/ci/README.md @@ -1,3 +1,7 @@ -# GitLab +# Coverage reporting -This script is for reporting the code coverage outputs to [Gitlab](https://github.com/NVIDIA/elements/-/graphs/main/charts). +The `coverage` task merges the Istanbul output from each package that runs `test:coverage`. It writes one XML coverage report to `coverage/cobertura.xml` for GitHub Code Quality. + +The GitHub Actions workflow uploads coverage for pushes to `main` and for pull requests from branches in this repository. GitHub does not support coverage uploads from fork pull requests. + +Enable GitHub Code Quality before running the upload workflow. After the first successful upload to `main`, configure the default branch ruleset to require at least 95% line coverage and allow a maximum decrease of 0.25 percentage points. diff --git a/projects/internals/ci/ci-summary.js b/projects/internals/ci/ci-summary.js index ba0f819c0b..23a9f1979a 100644 --- a/projects/internals/ci/ci-summary.js +++ b/projects/internals/ci/ci-summary.js @@ -1,24 +1,12 @@ import { readFileSync } from 'fs'; import path from 'path'; import * as url from 'url'; +import { COVERAGE_PROJECTS } from './coverage-projects.js'; // must not use node_modules dependencies, CI skips installation during this reporting call const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); const PROJECTS_ROOT = path.resolve(__dirname, '../..'); -const PROJECTS = [ - { name: '@nvidia-elements/core', dir: 'core' }, - { name: '@nvidia-elements/code', dir: 'code' }, - { name: '@nvidia-elements/cli', dir: 'cli' }, - { name: '@nvidia-elements/forms', dir: 'forms' }, - { name: '@nvidia-elements/lint', dir: 'lint' }, - { name: '@nvidia-elements/markdown', dir: 'markdown' }, - { name: '@nvidia-elements/media', dir: 'media' }, - { name: '@nvidia-elements/monaco', dir: 'monaco' }, - { name: '@internals/metadata', dir: 'internals/metadata' }, - { name: '@internals/tools', dir: 'internals/tools' } -]; - function tryRead(filePath) { try { return JSON.parse(readFileSync(filePath, 'utf-8')); @@ -35,7 +23,7 @@ function pctOf(covered, total) { return total > 0 ? (covered / total) * 100 : NaN; } -const rows = PROJECTS.map(({ name, dir }) => { +const rows = COVERAGE_PROJECTS.map(({ name, dir }) => { const base = path.join(PROJECTS_ROOT, dir, 'coverage/unit'); const coverage = tryRead(path.join(base, 'coverage-summary.json')); const tests = tryRead(path.join(base, 'summary.json')); diff --git a/projects/internals/ci/coverage-projects.js b/projects/internals/ci/coverage-projects.js new file mode 100644 index 0000000000..5a8f4cfdc8 --- /dev/null +++ b/projects/internals/ci/coverage-projects.js @@ -0,0 +1,14 @@ +export const COVERAGE_PROJECTS = [ + { name: '@nvidia-elements/core', dir: 'core' }, + { name: '@nvidia-elements/code', dir: 'code' }, + { name: '@nvidia-elements/cli', dir: 'cli' }, + { name: '@nvidia-elements/forms', dir: 'forms' }, + { name: '@nvidia-elements/lint', dir: 'lint' }, + { name: '@nvidia-elements/markdown', dir: 'markdown' }, + { name: '@nvidia-elements/media', dir: 'media' }, + { name: '@nvidia-elements/monaco', dir: 'monaco' }, + { name: '@nvidia-elements/plot', dir: 'plot' }, + { name: '@nvidia-elements/scene', dir: 'scene' }, + { name: '@internals/metadata', dir: 'internals/metadata' }, + { name: '@internals/tools', dir: 'internals/tools' } +]; diff --git a/projects/internals/ci/coverage.js b/projects/internals/ci/coverage.js index 77be9e0f29..7eaa2d75ec 100644 --- a/projects/internals/ci/coverage.js +++ b/projects/internals/ci/coverage.js @@ -1,92 +1,63 @@ -import { readFileSync } from 'fs'; -import path from 'path'; -import * as url from 'url'; -// must not use node_modules dependencies, CI skips installation during this reporting call +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createCoverageMap } from '@vitest/istanbul-lib-coverage'; +import { create, createContext } from '@vitest/istanbul-lib-report'; +import { COVERAGE_PROJECTS } from './coverage-projects.js'; -const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); -const resolve = rel => path.resolve(__dirname, rel); +const PROJECTS_ROOT = path.resolve(import.meta.dirname, '../..'); +const REPOSITORY_ROOT = path.resolve(PROJECTS_ROOT, '..'); +const OUTPUT_DIRECTORY = path.resolve(import.meta.dirname, 'coverage'); -const elementsCoverage = JSON.parse(readFileSync(resolve('../../core/coverage/unit/coverage-summary.json'))); -const labsCodeCoverage = JSON.parse(readFileSync(resolve('../../code/coverage/unit/coverage-summary.json'))); -const monacoCoverage = JSON.parse(readFileSync(resolve('../../monaco/coverage/unit/coverage-summary.json'))); -const labsFormsCoverage = JSON.parse(readFileSync(resolve('../../forms/coverage/unit/coverage-summary.json'))); -const labsCliCoverage = JSON.parse(readFileSync(resolve('../../cli/coverage/unit/coverage-summary.json'))); -const labsLintCoverage = JSON.parse(readFileSync(resolve('../../lint/coverage/unit/coverage-summary.json'))); -const labsMarkdownCoverage = JSON.parse(readFileSync(resolve('../../markdown/coverage/unit/coverage-summary.json'))); -const labsMediaCoverage = JSON.parse(readFileSync(resolve('../../media/coverage/unit/coverage-summary.json'))); -const internalsMetadataCoverage = JSON.parse(readFileSync(resolve('../metadata/coverage/unit/coverage-summary.json'))); -const internalsToolsCoverage = JSON.parse(readFileSync(resolve('../tools/coverage/unit/coverage-summary.json'))); +function readCoverageReport(project, projectsRoot) { + const reportPath = path.join(projectsRoot, project.dir, 'coverage/unit/coverage-final.json'); -const report = [ - elementsCoverage, - labsCodeCoverage, - labsFormsCoverage, - labsCliCoverage, - labsLintCoverage, - labsMarkdownCoverage, - labsMediaCoverage, - monacoCoverage, - internalsMetadataCoverage, - internalsToolsCoverage -] - .map(r => r.total) - .reduce( - (all, report) => { - return { - lines: { - total: all.lines.total + report.lines.total, - covered: all.lines.covered + report.lines.covered, - pct: (all.lines.covered + report.lines.covered) / (all.lines.total + report.lines.total) - }, - statements: { - total: all.statements.total + report.statements.total, - covered: all.statements.covered + report.statements.covered, - pct: (all.statements.covered + report.statements.covered) / (all.statements.total + report.statements.total) - }, - functions: { - total: all.functions.total + report.functions.total, - covered: all.functions.covered + report.functions.covered, - pct: (all.functions.covered + report.functions.covered) / (all.functions.total + report.functions.total) - }, - branches: { - total: all.branches.total + report.branches.total, - covered: all.branches.covered + report.branches.covered, - pct: (all.branches.covered + report.branches.covered) / (all.branches.total + report.branches.total) - } - }; - }, - { - lines: { - total: 0, - covered: 0, - pct: 0 - }, - statements: { - total: 0, - covered: 0, - pct: 0 - }, - functions: { - total: 0, - covered: 0, - pct: 0 - }, - branches: { - total: 0, - covered: 0, - pct: 0 - } + try { + return JSON.parse(readFileSync(reportPath, 'utf-8')); + } catch (error) { + throw new Error(`Unable to read coverage for ${project.name} at ${reportPath}`, { cause: error }); + } +} + +function assertRepositoryPaths(coverageMap, repositoryRoot) { + for (const filename of coverageMap.files()) { + const relative = path.relative(repositoryRoot, filename); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Coverage file is outside the repository: ${filename}`); + } + } +} + +export function generateCoverageReport({ + projects = COVERAGE_PROJECTS, + projectsRoot = PROJECTS_ROOT, + repositoryRoot = REPOSITORY_ROOT, + outputDirectory = OUTPUT_DIRECTORY, + outputFile = 'cobertura.xml' +} = {}) { + const coverageMap = createCoverageMap({}); + + for (const project of projects) { + try { + coverageMap.merge(readCoverageReport(project, projectsRoot)); + } catch (error) { + if (error.message.startsWith('Unable to read coverage')) throw error; + throw new Error(`Unable to merge coverage for ${project.name}`, { cause: error }); } - ); + } + + assertRepositoryPaths(coverageMap, repositoryRoot); + + const summary = coverageMap.getCoverageSummary(); + if (summary.lines.total === 0) throw new Error('Coverage report does not contain any source lines'); + + const context = createContext({ dir: outputDirectory, coverageMap }); + create('cobertura', { file: outputFile, projectRoot: repositoryRoot }).execute(context); + + return summary.toJSON(); +} -console.log(`Report:\n`, report, '\n'); -console.log(`@nvidia-elements/core coverage: ${elementsCoverage.total.branches.pct}%`); -console.log(`@nvidia-elements/monaco coverage: ${monacoCoverage.total.branches.pct}%`); -console.log(`@nvidia-elements/cli coverage: ${labsCliCoverage.total.branches.pct}%`); -console.log(`@nvidia-elements/code coverage: ${labsCodeCoverage.total.branches.pct}%`); -console.log(`@nvidia-elements/forms coverage: ${labsFormsCoverage.total.branches.pct}%`); -console.log(`@nvidia-elements/lint coverage: ${labsLintCoverage.total.branches.pct}%`); -console.log(`@nvidia-elements/markdown coverage: ${labsMarkdownCoverage.total.branches.pct}%`); -console.log(`@internals/metadata coverage: ${internalsMetadataCoverage.total.branches.pct}%`); -console.log(`@internals/tools coverage: ${internalsToolsCoverage.total.branches.pct}%`); -console.log(`average coverage: ${((report.branches.covered / report.branches.total) * 100).toFixed(2)}%`); +if (path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const summary = generateCoverageReport(); + console.log(`Generated ${path.join(OUTPUT_DIRECTORY, 'cobertura.xml')} (${summary.lines.pct}% line coverage)`); +} diff --git a/projects/internals/ci/package.json b/projects/internals/ci/package.json index 95caf98042..1be58acbd1 100644 --- a/projects/internals/ci/package.json +++ b/projects/internals/ci/package.json @@ -10,26 +10,48 @@ "wireit": { "ci": { "dependencies": [ - "test" + "test", + "coverage" ] }, "coverage": { "command": "node ./coverage.js", - "packageLocks": [] + "dependencies": [ + "../../cli:test:coverage", + "../../code:test:coverage", + "../../core:test:coverage", + "../../forms:test:coverage", + "../../lint:test:coverage", + "../../markdown:test:coverage", + "../../media:test:coverage", + "../../monaco:test:coverage", + "../../plot:test:coverage", + "../../scene:test:coverage", + "../metadata:test:coverage", + "../tools:test:coverage" + ], + "files": [ + "coverage.js", + "coverage-projects.js" + ], + "output": [ + "coverage/cobertura.xml" + ] }, "test": { "command": "node --test './*.test.js'", "files": [ "ci-profile.js", "ci-profile.test.js", + "coverage-projects.js", + "coverage.js", "lighthouse-projects.js", "lighthouse-summary.js", "metrics.lighthouse.js", "release-notes.test.js", "package.json" ], - "output": [], - "packageLocks": [] + "output": [] } } } diff --git a/projects/internals/vite/src/configs/test.js b/projects/internals/vite/src/configs/test.js index 308bc939b9..587acf2d33 100644 --- a/projects/internals/vite/src/configs/test.js +++ b/projects/internals/vite/src/configs/test.js @@ -5,11 +5,10 @@ import { hideExpectedTestConsoleMessage } from './console.js'; import { getElementsEnv } from './env.js'; const watch = process.argv.findIndex(i => i === '--watch') !== -1; -const coverage = process.argv.findIndex(i => i === '--coverage') !== -1; const browser = { browser: 'chromium', - isolate: coverage + isolate: true }; Object.defineProperty(browser, 'name', { @@ -46,7 +45,7 @@ export const libraryTestConfig = { fileParallelism: !process.env.CI, // Disable file parallelism in CI to reduce browser instances maxWorkers: process.env.CI ? 1 : undefined, maxConcurrency: process.env.CI ? 1 : undefined, // Limit concurrent tests to avoid browser overload - isolate: coverage, + isolate: true, testTimeout: 60000, hookTimeout: 30000, server: { @@ -106,7 +105,7 @@ export const libraryTestConfig = { extension: ['.ts'], provider: 'istanbul', reportsDirectory: './coverage/unit', - reporter: [['lcov', { file: 'coverage.dat' }], 'html', 'json-summary'], + reporter: [['lcov', { file: 'coverage.dat' }], 'html', 'json', 'json-summary'], thresholds: { lines: 90, branches: 90, diff --git a/projects/internals/vite/src/configs/test.node.js b/projects/internals/vite/src/configs/test.node.js index b8516d1d50..6b7c1c21dc 100644 --- a/projects/internals/vite/src/configs/test.node.js +++ b/projects/internals/vite/src/configs/test.node.js @@ -46,7 +46,7 @@ export const libraryNodeTestConfig = { extension: ['.ts'], provider: 'istanbul', reportsDirectory: './coverage/unit', - reporter: [['lcov', { file: 'coverage.dat' }], 'html', 'json-summary'], + reporter: [['lcov', { file: 'coverage.dat' }], 'html', 'json', 'json-summary'], thresholds: { lines: 90, branches: 90, diff --git a/projects/markdown/package.json b/projects/markdown/package.json index db22617e18..da9875b610 100644 --- a/projects/markdown/package.json +++ b/projects/markdown/package.json @@ -203,7 +203,9 @@ "vite.config.ts", "vitest.config.ts" ], - "output": [], + "output": [ + "coverage/unit/**" + ], "dependencies": [ "../internals/testing:build", "../internals/vite:ci", diff --git a/projects/monaco/package.json b/projects/monaco/package.json index f5eef81665..9b52cf7489 100644 --- a/projects/monaco/package.json +++ b/projects/monaco/package.json @@ -348,7 +348,9 @@ "tsconfig.json", "vitest.config.ts" ], - "output": [], + "output": [ + "coverage/unit/**" + ], "dependencies": [ "../internals/testing:build", "../internals/vite:ci",