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: