From b94be087d35e6d4d6b8353bc436267b4662c204a Mon Sep 17 00:00:00 2001 From: siddhant-galileo Date: Mon, 9 Mar 2026 16:30:06 +0530 Subject: [PATCH] feat(ui): code coverage reporting for ui --- .github/workflows/ci.yml | 11 ++- ui/.gitignore | 2 + ui/package.json | 11 ++- ui/pnpm-lock.yaml | 65 +++++++++++++ ui/scripts/coverage-playwright.cjs | 142 +++++++++++++++++++++++++++++ ui/tests/fixtures.ts | 32 ++++++- 6 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 ui/scripts/coverage-playwright.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1c80960..f0caf389 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,9 +136,9 @@ jobs: # Skip unnecessary optimizations for test builds SKIP_ENV_VALIDATION: true - - name: Run integration tests + - name: Run integration tests with coverage working-directory: ./ui - run: pnpm run test:integration + run: pnpm run test:integration:coverage env: # Use production build for faster startup NODE_ENV: production @@ -159,6 +159,13 @@ jobs: path: ui/test-results/ retention-days: 3 + - name: Upload UI coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ui/coverage-playwright/lcov.info + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + sdk-ts-ci: runs-on: ubuntu-latest env: diff --git a/ui/.gitignore b/ui/.gitignore index c8fc9c98..2f5dd5fb 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -42,3 +42,5 @@ next-env.d.ts CLAUDE.md .claude + +playwright-coverage/ \ No newline at end of file diff --git a/ui/package.json b/ui/package.json index 863b99bb..31a65abc 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit", "fetch-api-types": "openapi-typescript http://localhost:8000/openapi.json -o src/core/api/generated/api-types.ts", "test:integration": "playwright test", + "test:integration:coverage": "playwright test && node scripts/coverage-playwright.cjs", "test:integration:ui": "playwright test --ui", "test:integration:headed": "playwright test --headed", "test:integration:debug": "playwright test --debug", @@ -75,13 +76,21 @@ "eslint-config-next": "16.1.1", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.3.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", "npm-scripts-info": "^0.3.9", "openapi-typescript": "^7.10.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.7", "postcss-preset-mantine": "1.17.0", "postcss-simple-vars": "7.0.1", "prettier": "^3.4.2", "tailwindcss": "^4", + "v8-to-istanbul": "^9.3.0", "typescript": "^5", - "typescript-eslint": "^8.32.1" + "typescript-eslint": "^8.32.1", + "v8-to-istanbul": "^9.3.0" } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 15095d63..c659e035 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -117,6 +117,15 @@ importers: eslint-plugin-unused-imports: specifier: ^4.3.0 version: 4.3.0(@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) + istanbul-lib-coverage: + specifier: ^3.2.2 + version: 3.2.2 + istanbul-lib-report: + specifier: ^3.0.1 + version: 3.0.1 + istanbul-reports: + specifier: ^3.2.0 + version: 3.2.0 npm-scripts-info: specifier: ^0.3.9 version: 0.3.9 @@ -141,6 +150,9 @@ importers: typescript-eslint: specifier: ^8.32.1 version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + v8-to-istanbul: + specifier: ^9.3.0 + version: 9.3.0 packages: @@ -802,6 +814,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1709,6 +1724,9 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -1861,6 +1879,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -2038,6 +2068,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + map-obj@1.0.1: resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} engines: {node: '>=0.10.0'} @@ -2805,6 +2839,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -3493,6 +3531,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/istanbul-lib-coverage@2.0.6': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -4530,6 +4570,8 @@ snapshots: hosted-git-info@2.8.9: {} + html-escaper@2.0.2: {} + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 @@ -4680,6 +4722,19 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -4833,6 +4888,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + map-obj@1.0.1: {} map-obj@2.0.0: {} @@ -5685,6 +5744,12 @@ snapshots: util-deprecate@1.0.2: {} + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/ui/scripts/coverage-playwright.cjs b/ui/scripts/coverage-playwright.cjs new file mode 100644 index 00000000..98be822f --- /dev/null +++ b/ui/scripts/coverage-playwright.cjs @@ -0,0 +1,142 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +// Post-process Playwright raw V8 coverage into an Istanbul HTML report. +// +// Input: +// - JSON files under playwright-coverage/raw//*.json +// (emitted by the shared Playwright fixture in tests/fixtures.ts) +// +// Output: +// - Istanbul HTML + text-summary under coverage-playwright/ +// - LCOV file (lcov.info) under coverage-playwright/ for Codecov + +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const v8ToIstanbul = require('v8-to-istanbul'); +const istanbulLibCoverage = require('istanbul-lib-coverage'); +const istanbulLibReport = require('istanbul-lib-report'); +const istanbulReports = require('istanbul-reports'); + +async function listJsonFiles(dir) { + const files = []; + async function walk(current) { + let entries; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch (err) { + if (err && err.code === 'ENOENT') return; + throw err; + } + + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.isFile() && entry.name.endsWith('.json')) { + files.push(fullPath); + } + } + } + + await walk(dir); + return files; +} + +async function main() { + const cwd = process.cwd(); + const rawRoot = path.join(cwd, 'playwright-coverage', 'raw'); + + const files = await listJsonFiles(rawRoot); + if (files.length === 0) { + // eslint-disable-next-line no-console + console.warn( + '[coverage-playwright] No raw coverage files found under', + rawRoot + ); + return; + } + + const coverageMap = istanbulLibCoverage.createCoverageMap({}); + + for (const file of files) { + // eslint-disable-next-line no-console + console.log('[coverage-playwright] Processing', path.relative(cwd, file)); + const jsonText = await fs.readFile(file, 'utf-8'); + /** @type {Array<{ url?: string; source?: string; functions: any[] }>} */ + const entries = JSON.parse(jsonText); + + for (const entry of entries) { + if (!entry || !entry.source || !entry.functions) continue; + + // Use the URL path (if present) as a pseudo file path in reports. + let virtualPath = entry.url || 'anonymous-script.js'; + try { + if (virtualPath.startsWith('http://') || virtualPath.startsWith('https://')) { + const u = new URL(virtualPath); + virtualPath = u.pathname || virtualPath; + } + } catch { + // Keep original value if URL parsing fails + } + + const converter = v8ToIstanbul(virtualPath, 0, { + source: entry.source, + }); + await converter.load(); + await converter.applyCoverage(entry.functions); + + const fileCoverage = converter.toIstanbul(); + + // Only keep coverage for UI application sources under src/. + // This filters out Next.js internals and node_modules noise. + const filtered = istanbulLibCoverage.createCoverageMap({}); + for (const filePath of Object.keys(fileCoverage)) { + if ( + filePath.includes('/src/') || + filePath.includes('\\src\\') || + filePath.startsWith('src/') + ) { + filtered.addFileCoverage(fileCoverage[filePath]); + } + } + + coverageMap.merge(filtered); + } + } + + const outDir = path.join(cwd, 'coverage-playwright'); + await fs.mkdir(outDir, { recursive: true }); + + const context = istanbulLibReport.createContext({ + dir: outDir, + coverageMap, + }); + + const reports = [ + istanbulReports.create('html'), + istanbulReports.create('text-summary'), + // LCOV format that Codecov, Coveralls, etc. understand. + istanbulReports.create('lcovonly', { file: 'lcov.info' }), + ]; + + for (const report of reports) { + report.execute(context); + } + + // eslint-disable-next-line no-console + console.log( + '[coverage-playwright] HTML report written to', + path.join('coverage-playwright', 'index.html') + ); + console.log( + '[coverage-playwright] LCOV report written to', + path.join('coverage-playwright', 'lcov.info') + ); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('[coverage-playwright] Failed:', err); + process.exitCode = 1; +}); + diff --git a/ui/tests/fixtures.ts b/ui/tests/fixtures.ts index 04263ed5..ec738c50 100644 --- a/ui/tests/fixtures.ts +++ b/ui/tests/fixtures.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + import { type Page, test as base } from '@playwright/test'; import type { @@ -640,13 +643,38 @@ export async function mockApiRoutes(page: Page) { } /** - * Extended test with mocked API + * Extended test with mocked API and optional JS coverage collection. + * Coverage is collected for Chromium runs and written as raw V8 coverage + * JSON under each project's output directory (e.g. playwright-report/). */ export const test = base.extend<{ mockedPage: Page }>({ /* eslint-disable react-hooks/rules-of-hooks */ - mockedPage: async ({ page }, use) => { + mockedPage: async ({ page }, use, testInfo) => { + const shouldCollectCoverage = testInfo.project.name === 'chromium'; + + if (shouldCollectCoverage) { + // Only JS coverage; CSS is usually less interesting for app logic. + await page.coverage.startJSCoverage(); + } + await mockApiRoutes(page); await use(page); + + if (shouldCollectCoverage) { + const jsCoverage = await page.coverage.stopJSCoverage(); + + const coverageDir = path.join( + process.cwd(), + 'playwright-coverage', + 'raw', + testInfo.project.name + ); + await fs.mkdir(coverageDir, { recursive: true }); + + const safeTestId = testInfo.testId.replace(/[^a-zA-Z0-9_-]/g, '_'); + const filePath = path.join(coverageDir, `${safeTestId}.json`); + await fs.writeFile(filePath, JSON.stringify(jsCoverage), 'utf-8'); + } }, /* eslint-enable react-hooks/rules-of-hooks */ });