From 7828d901c9d4f9530e21ffb5291cd00405fff921 Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:25:01 +0000 Subject: [PATCH] feat(#4267): improve scorecard code-coverage module Add documentation, demo data, and e2e tests for the scorecard code-coverage module. - Add "Importing data" section to the module README with curl examples for Cobertura XML and LCOV formats - Create examples/code-coverage/ with a demo Cobertura XML report and an import.sh script for one-command data seeding - Add Playwright e2e tests verifying all 8 code-coverage metrics (line/branch percentage, available, covered, missed) display correctly, metric values match mocked data, and empty state renders when no metrics are available - Add code-coverage metric provider schedule to app-config.yaml Backend-to-backend authentication was already implemented in #3476 using AuthService.getOwnServiceCredentials() and getPluginRequestToken(), verified by existing unit tests. E2e tests use route-intercepted mock responses (the standard pattern in this workspace) rather than live data import. The demo data in examples/code-coverage/ is available for manual import into a running Backstage instance. Closes #4267 --- workspaces/scorecard/app-config.yaml | 6 ++ .../code-coverage/cobertura-coverage.xml | 58 +++++++++++++ .../examples/code-coverage/import.sh | 49 +++++++++++ .../app-legacy/e2e-tests/scorecard.test.ts | 83 +++++++++++++++++++ .../app-legacy/e2e-tests/utils/apiUtils.ts | 17 ++++ .../e2e-tests/utils/scorecardResponseUtils.ts | 76 +++++++++++++++++ .../README.md | 47 +++++++++++ 7 files changed, 336 insertions(+) create mode 100644 workspaces/scorecard/examples/code-coverage/cobertura-coverage.xml create mode 100755 workspaces/scorecard/examples/code-coverage/import.sh diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index c6d42f95984..fc4259c6079 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -395,6 +395,12 @@ scorecard: frequency: { minutes: 5 } timeout: { minutes: 10 } initialDelay: { seconds: 10 } + codeCoverage: + coverageReport: + schedule: + frequency: { minutes: 5 } + timeout: { minutes: 10 } + initialDelay: { seconds: 10 } filecheck: fileExistence: options: diff --git a/workspaces/scorecard/examples/code-coverage/cobertura-coverage.xml b/workspaces/scorecard/examples/code-coverage/cobertura-coverage.xml new file mode 100644 index 00000000000..e142f9db2e4 --- /dev/null +++ b/workspaces/scorecard/examples/code-coverage/cobertura-coverage.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/workspaces/scorecard/examples/code-coverage/import.sh b/workspaces/scorecard/examples/code-coverage/import.sh new file mode 100755 index 00000000000..d4179b27f62 --- /dev/null +++ b/workspaces/scorecard/examples/code-coverage/import.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# Import demo code-coverage data into a local Backstage instance. +# +# Prerequisites: +# - A running Backstage backend at BACKSTAGE_URL (default: http://localhost:7007) +# - The code-coverage-backend plugin installed and enabled +# - The entity "component:default/code-coverage-scorecard-only" registered +# in the catalog (included in examples/all-scorecards-location.yaml) +# +# Usage: +# ./import.sh # import with defaults +# BACKSTAGE_URL=http://host:7007 ./import.sh # custom backend URL +# ENTITY_REF=component:default/my-svc ./import.sh # custom entity +# --------------------------------------------------------------------------- +set -euo pipefail + +BACKSTAGE_URL="${BACKSTAGE_URL:-http://localhost:7007}" +ENTITY_REF="${ENTITY_REF:-component:default/code-coverage-scorecard-only}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +COVERAGE_FILE="${SCRIPT_DIR}/cobertura-coverage.xml" + +if [ ! -f "${COVERAGE_FILE}" ]; then + echo "Error: coverage file not found: ${COVERAGE_FILE}" >&2 + exit 1 +fi + +ENCODED_ENTITY=$(printf '%s' "${ENTITY_REF}" | sed 's/:/%3A/g; s/\//%2F/g') +URL="${BACKSTAGE_URL}/api/code-coverage/report?entity=${ENCODED_ENTITY}&coverageType=cobertura" + +echo "Importing code-coverage data..." +echo " Backend: ${BACKSTAGE_URL}" +echo " Entity: ${ENTITY_REF}" +echo " File: ${COVERAGE_FILE}" +echo "" + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${URL}" \ + -H 'Content-Type: text/xml' \ + ${SERVICE_TOKEN:+-H "Authorization: Bearer ${SERVICE_TOKEN}"} \ + --data-binary "@${COVERAGE_FILE}") + +if [ "${HTTP_CODE}" -ge 200 ] && [ "${HTTP_CODE}" -lt 300 ]; then + echo "Success (HTTP ${HTTP_CODE}): coverage data imported for ${ENTITY_REF}" +else + echo "Error (HTTP ${HTTP_CODE}): failed to import coverage data" >&2 + echo "Verify the code-coverage-backend plugin is running and the entity is registered." >&2 + exit 1 +fi diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts index 1109cbe0950..8269f5425b3 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts @@ -23,6 +23,7 @@ import { mockMetricsApi, mockApiResponse, mockSonarqubeScorecardResponse, + mockCodeCoverageScorecardResponse, } from './utils/apiUtils'; import { CatalogPage } from './pages/CatalogPage'; import { ScorecardPage } from './pages/ScorecardPage'; @@ -48,6 +49,7 @@ import { sonarqubeScorecardResponse, sonarqubeFailedQualityGateResponse, fileCheckScorecardResponse, + codeCoverageScorecardResponse, githubCustomAggregatedResponse, gitHubPartiallyAggregatedResponse, gitHubWeightedPartiallyAggregatedResponse, @@ -423,6 +425,87 @@ test.describe('Scorecard Plugin Tests', () => { }); }); + test.describe('Code Coverage Entity Scorecards', () => { + test.skip( + process.env.APP_MODE === 'nfs', + 'Legacy-only: NFS uses grouped metric cards', + ); + + test('Verify all code coverage metrics display correctly', async ({}, testInfo) => { + await mockCodeCoverageScorecardResponse( + page, + codeCoverageScorecardResponse, + ); + + await catalogPage.openCatalog(); + await catalogPage.openComponent('code-coverage-scorecard-only'); + await page.getByText('Scorecard', { exact: true }).click(); + + const expectedTitles = [ + 'Code coverage (Lines)', + 'Code coverage - Tracked lines of code', + 'Code coverage - Covered lines of code', + 'Code coverage - Missed lines of code', + 'Code coverage (Branches)', + 'Code coverage - Tracked branches', + 'Code coverage - Covered branches', + 'Code coverage - Missed branches', + ]; + + for (const title of expectedTitles) { + await expect( + page.getByText(title, { exact: true }).first(), + ).toBeVisible({ timeout: 10000 }); + } + + await runAccessibilityTests(page, testInfo); + }); + + test('Verify code coverage metric values', async () => { + await mockCodeCoverageScorecardResponse( + page, + codeCoverageScorecardResponse, + ); + + await catalogPage.openCatalog(); + await catalogPage.openComponent('code-coverage-scorecard-only'); + await page.getByText('Scorecard', { exact: true }).click(); + + await expect( + page.getByText('Code coverage (Lines)', { exact: true }), + ).toBeVisible({ timeout: 10000 }); + + const expectedValues: Record = { + 'Code coverage (Lines)': '80', + 'Code coverage - Tracked lines of code': '200', + 'Code coverage - Covered lines of code': '160', + 'Code coverage - Missed lines of code': '40', + 'Code coverage (Branches)': '70', + 'Code coverage - Tracked branches': '50', + 'Code coverage - Covered branches': '35', + 'Code coverage - Missed branches': '15', + }; + + for (const [title, value] of Object.entries(expectedValues)) { + const card = page + .locator('[role="article"]') + .filter({ hasText: title }) + .first(); + await expect(card).toContainText(value); + } + }); + + test('Verify empty state for code-coverage entity with no metrics', async () => { + await mockCodeCoverageScorecardResponse(page, emptyScorecardResponse); + + await catalogPage.openCatalog(); + await catalogPage.openComponent('code-coverage-scorecard-only'); + await page.getByText('Scorecard', { exact: true }).click(); + + await expect(page.getByText(translations.emptyState.title)).toBeVisible(); + }); + }); + test.describe('Homepage aggregated scorecards', () => { test('Verify missing permission on all default homepage scorecard widgets', async () => { await mockHomepageAggregationsPermissionDenied(page); diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/apiUtils.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/apiUtils.ts index cf6e17a6857..4c905a38813 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/apiUtils.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/apiUtils.ts @@ -111,6 +111,23 @@ export async function mockApiTextResponse( }); } +const CODE_COVERAGE_SCORECARD_API_ROUTE = + '**/api/scorecard/metrics/catalog/Component/default/code-coverage-scorecard-only'; + +export async function mockCodeCoverageScorecardResponse( + page: Page, + responseData: object, + status = 200, +) { + await page.route(CODE_COVERAGE_SCORECARD_API_ROUTE, async route => { + await route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(responseData), + }); + }); +} + const SONARQUBE_SCORECARD_API_ROUTE = '**/api/scorecard/metrics/catalog/Component/default/sonarqube-scorecard-only'; diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts index 07b49d7e8d5..1999d7c7b43 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts @@ -860,6 +860,82 @@ export const jiraEntitiesDrillDownNoDataResponse = { }, }; +// Code coverage scorecard responses — 8 metrics matching CodeCoverageMetricProvider +export const codeCoverageScorecardResponse = [ + sonarqubeNumberMetric( + 'codeCoverage.linePercentage', + 'Code coverage (Lines)', + 'Percentage of lines covered by tests.', + 80, + [ + { key: 'success', expression: '>80' }, + { key: 'warning', expression: '50-80' }, + { key: 'error', expression: '<50' }, + ], + 'warning', + ), + sonarqubeNumberMetric( + 'codeCoverage.lineAvailable', + 'Code coverage - Tracked lines of code', + 'Total number of lines tracked for code coverage.', + 200, + [], + 'success', + ), + sonarqubeNumberMetric( + 'codeCoverage.lineCovered', + 'Code coverage - Covered lines of code', + 'Number of lines covered by tests.', + 160, + [], + 'success', + ), + sonarqubeNumberMetric( + 'codeCoverage.lineMissed', + 'Code coverage - Missed lines of code', + 'Number of lines not covered by tests.', + 40, + [], + 'success', + ), + sonarqubeNumberMetric( + 'codeCoverage.branchPercentage', + 'Code coverage (Branches)', + 'Percentage of branches covered by tests.', + 70, + [ + { key: 'success', expression: '>80' }, + { key: 'warning', expression: '50-80' }, + { key: 'error', expression: '<50' }, + ], + 'warning', + ), + sonarqubeNumberMetric( + 'codeCoverage.branchAvailable', + 'Code coverage - Tracked branches', + 'Total number of branches tracked for code coverage.', + 50, + [], + 'success', + ), + sonarqubeNumberMetric( + 'codeCoverage.branchCovered', + 'Code coverage - Covered branches', + 'Number of branches covered by tests.', + 35, + [], + 'success', + ), + sonarqubeNumberMetric( + 'codeCoverage.branchMissed', + 'Code coverage - Missed branches', + 'Number of branches not covered by tests.', + 15, + [], + 'success', + ), +]; + export const fileCheckScorecardResponse = [ { id: 'filecheck.readme', diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/README.md index 9d50507b905..d35dc981013 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/README.md @@ -40,6 +40,53 @@ spec: lifecycle: production ``` +## Importing data + +This module reads coverage reports from the +[code-coverage-backend](https://github.com/backstage/community-plugins/tree/main/workspaces/code-coverage/plugins/code-coverage-backend) +plugin. You must first import coverage data into that plugin before the +scorecard can display metrics. + +### Cobertura XML + +```bash +curl -X POST \ + 'http://localhost:7007/api/code-coverage/report?entity=component:default/my-service&coverageType=cobertura' \ + -H 'Content-Type: text/xml' \ + -H "Authorization: Bearer $SERVICE_TOKEN" \ + --data-binary @coverage/cobertura-coverage.xml +``` + +### LCOV + +```bash +curl -X POST \ + 'http://localhost:7007/api/code-coverage/report?entity=component:default/my-service&coverageType=lcov' \ + -H 'Content-Type: text/plain' \ + -H "Authorization: Bearer $SERVICE_TOKEN" \ + --data-binary @coverage/lcov.info +``` + +### Query parameters + +| Parameter | Description | +| -------------- | ---------------------------------------------------- | +| `entity` | Full entity ref, e.g. `component:default/my-service` | +| `coverageType` | Format of the coverage file: `cobertura` or `lcov` | + +### Demo data + +The `examples/code-coverage/` directory in this workspace contains a +ready-to-use Cobertura XML report and an import script: + +```bash +cd workspaces/scorecard +./examples/code-coverage/import.sh +``` + +The script imports the demo report for the `code-coverage-scorecard-only` +entity. See the script source for details and customization. + ## Installation Add the module to your backend: