diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/constants/aggregations.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/constants/aggregations.ts index 521a09f9955..03d5d59afa9 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/constants/aggregations.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/constants/aggregations.ts @@ -20,6 +20,7 @@ export const AGGREGATED_CARDS_METRIC_IDS = { githubOpenPrsKpi: 'openPrsKpi', jiraOpenIssuesKpi: 'openIssuesKpi', gitHubOpenPrsWeightedKpi: 'openPrsWeightedKpi', + licenseFileExistsKpi: 'licenseFileExistsKpi', } as const; /** Must match `title` in App.tsx homepage widget config (Add widget picker). */ @@ -29,6 +30,7 @@ export const AGGREGATED_CARDS_WIDGET_TITLES = { githubOpenPrsKpi: 'Scorecard: GitHub open PRs', jiraOpenIssuesKpi: 'Scorecard: Jira open blocking tickets', gitHubOpenPrsWeightedKpi: 'Scorecard: GitHub open PRs (weighted health)', + licenseFileExistsKpi: 'Scorecard: LICENSE file exists', } as const; export const AGGREGATED_CARDS_METADATA = { @@ -57,4 +59,9 @@ export const AGGREGATED_CARDS_METADATA = { title: 'Scorecard: GitHub open PRs (weighted health)', metricId: 'github.openPRs', }, + licenseFileExistsKpi: { + id: AGGREGATED_CARDS_METRIC_IDS.licenseFileExistsKpi, + title: 'Scorecard: LICENSE file exists', + metricId: 'filecheck.license', + }, } as const; diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/constants/routes.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/constants/routes.ts index 7d0c7fcea6b..a8c99abb4d0 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/constants/routes.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/constants/routes.ts @@ -33,4 +33,6 @@ export const ScorecardRoutes = { '**/api/scorecard/aggregations/jira.openIssues', GITHUB_OPEN_PRS_METRIC_AGGREGATION_ROUTE: '**/api/scorecard/aggregations/github.openPRs', + LICENSE_FILE_EXISTS_KPI_AGGREGATION_ROUTE: + '**/api/scorecard/aggregations/licenseFileExistsKpi', } as const; diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts index 034be0215a0..f3860029bf8 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts @@ -69,6 +69,11 @@ export class HomePage { ) { cardPattern = /Scorecard:\s*GitHub open PRs \(weighted health\)|ScorecardGitHubOpenPrsWeightedKpi/i; + } else if ( + cardName === AGGREGATED_CARDS_WIDGET_TITLES.licenseFileExistsKpi + ) { + cardPattern = + /Scorecard:\s*LICENSE file exists|scorecard-filecheck\.license/i; } else { cardPattern = new RegExp(escapeRegex(cardName), 'i'); } diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/ScorecardDrillDownPage.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/ScorecardDrillDownPage.ts index b10b3e5dfc4..7eef91d6c83 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/ScorecardDrillDownPage.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/ScorecardDrillDownPage.ts @@ -26,7 +26,7 @@ import { getSomeEntitiesNotReportingTooltip, } from '../utils/translationUtils'; -type MetricId = 'github.openPRs' | 'jira.openIssues'; +type MetricId = 'github.openPRs' | 'jira.openIssues' | 'filecheck.license'; export type DrillDownCardLocatorOptions = { aggregationId?: string; @@ -167,19 +167,30 @@ export class ScorecardDrillDownPage { * When mocks report no calculation failures, the drill-down must not show the * calculation-warning icon next to the Entities heading. */ - async expectNoDrillDownCalculationErrorWarningIcon() { - const heading = this.page.getByRole('heading', { - level: 3, - name: this.translations.entitiesPage.entitiesTable.title, + private getEntitiesTableHeading(): Locator { + const titlePrefix = this.translations.entitiesPage.entitiesTable.title; + return this.page.getByRole('heading', { level: 3 }).filter({ + hasText: titlePrefix, }); - await expect(heading.locator('svg.MuiSvgIcon-colorWarning')).toHaveCount(0); } - /** Verifies the "some entities not reporting" icon tooltip on the drill-down card. */ - async verifySomeEntitiesNotReportingTooltip() { - const icon = this.page.getByTestId('ReportProblemOutlinedIcon'); - await expect(icon).toBeVisible(); - await icon.hover(); + private getEntitiesTableCalculationWarningIcon(): Locator { + return this.getEntitiesTableHeading().getByTestId( + 'entities-table-calculation-warning-icon', + ); + } + + async expectNoDrillDownCalculationErrorWarningIcon() { + await expect(this.getEntitiesTableCalculationWarningIcon()).toHaveCount(0); + } + + async expectDrillDownCalculationErrorWarningIcon() { + await expect(this.getEntitiesTableCalculationWarningIcon()).toBeVisible(); + } + + /** Verifies the calculation-error tooltip on the Entities table heading icon. */ + async verifyEntitiesTableCalculationErrorTooltip() { + await this.getEntitiesTableCalculationWarningIcon().hover(); const tooltipText = getSomeEntitiesNotReportingTooltip(this.translations); await expect(this.page.getByRole('tooltip')).toContainText(tooltipText); } @@ -220,6 +231,25 @@ export class ScorecardDrillDownPage { } } + private getEntityLink(entitySlug: string): Locator { + const slug = encodeURIComponent(entitySlug); + return this.getEntitiesTable() + .locator('tbody') + .locator(`a[href*="/catalog/default/component/${slug}"]`) + .first(); + } + + async clickEntityLink(entitySlug: string) { + await this.getEntityLink(entitySlug).click(); + } + + async expectOnEntityPage(entitySlug: string) { + const slug = encodeURIComponent(entitySlug); + await expect(this.page).toHaveURL( + new RegExp(`/catalog/default/component/${escapeRegex(slug)}`), + ); + } + async verifyMetricColumnSort() { const tableHeaders = getEntitiesTableHeaderLabels(this.translations); const entitiesTable = this.getEntitiesTable(); 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 4da130c7827..9d9b8c01e22 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts @@ -51,6 +51,9 @@ import { githubCustomAggregatedResponse, gitHubPartiallyAggregatedResponse, gitHubWeightedPartiallyAggregatedResponse, + licenseFileExistsAggregatedResponse, + githubEntitiesDrillDownWithCalculationErrorsResponse, + weightedKpiEntitiesDrillDownResponse, } from './utils/scorecardResponseUtils'; import { ScorecardMessages, @@ -471,6 +474,52 @@ test.describe('Scorecard Plugin Tests', () => { } }); + test.describe('Filecheck homepage KPI - licenseFileExistsKpi', () => { + let card: Locator; + const aggregationMetadata = + AGGREGATED_CARDS_METADATA.licenseFileExistsKpi; + const aggregatedResponse = licenseFileExistsAggregatedResponse; + + test.beforeAll(async () => { + await setupHomepageAggregationCard(page, homePage, { + aggregationMetadata, + route: ScorecardRoutes.LICENSE_FILE_EXISTS_KPI_AGGREGATION_ROUTE, + response: aggregatedResponse, + }); + card = homePage.getCard(aggregationMetadata.id); + }); + + test('Verify title and description', async () => { + await expect(card).toBeVisible(); + await expect(card).toContainText(aggregatedResponse.metadata.title); + await expect(card).toContainText( + aggregatedResponse.metadata.description, + ); + }); + + test('Verify exist and missing threshold buckets', async ({}, testInfo) => { + const existLabel = translations.thresholds.exist ?? 'Exist'; + const missingLabel = translations.thresholds.missing ?? 'Missing'; + await expect(card.getByText(existLabel, { exact: true })).toBeVisible(); + await expect( + card.getByText(missingLabel, { exact: true }), + ).toBeVisible(); + + await runAccessibilityTests(page, testInfo); + }); + + test('Verify drill-down link', async () => { + await homePage.clickDrillDownLink(card); + await scorecardDrillDownPage.expectOnPage('filecheck.license', { + aggregationId: aggregationMetadata.id, + }); + await scorecardDrillDownPage.expectPageTitle( + 'filecheck.license', + aggregatedResponse.metadata.title, + ); + }); + }); + test.describe('Deprecated homepage card (metricId only)', () => { let card: Locator; const aggregationMetadata = @@ -789,6 +838,26 @@ test.describe('Scorecard Plugin Tests', () => { openPrsWeightedAggregatedResponse.metadata.title, ); }); + + test('Verify drill-down entities table', async () => { + await mockScorecardEntitiesDrillDown( + page, + weightedKpiEntitiesDrillDownResponse, + 'github.openPRs', + ); + + await page.goto( + `/scorecard/aggregations/${aggregationMetadata.id}/metrics/github.openPRs`, + ); + + await scorecardDrillDownPage.expectOnPage('github.openPRs', { + aggregationId: aggregationMetadata.id, + }); + await scorecardDrillDownPage.expectTableHeadersVisible(); + await scorecardDrillDownPage.expectEntityNamesVisible([ + 'red-hat-developer-hub', + ]); + }); }); test('Verify empty aggregated response shows no data', async () => { @@ -953,6 +1022,56 @@ test.describe('Scorecard Plugin Tests', () => { await test.step('Verify metric column sort', async () => { await scorecardDrillDownPage.verifyMetricColumnSort(); }); + + await test.step('Navigate to entity from drill-down table', async () => { + await scorecardDrillDownPage.clickEntityLink('red-hat-developer-hub'); + await scorecardDrillDownPage.expectOnEntityPage( + 'red-hat-developer-hub', + ); + }); + }); + + test('GitHub drill-down: calculation errors show warning icon and tooltip', async () => { + const aggregationMetadata = + AGGREGATED_CARDS_METADATA.githubDefaultAggregation; + const partialAggregationResponse = { + ...githubAggregatedResponse, + result: { + ...githubAggregatedResponse.result, + calculationErrorCount: 2, + }, + }; + + await mockApiResponse( + page, + ScorecardRoutes.GITHUB_OPEN_PRS_METRIC_AGGREGATION_ROUTE, + partialAggregationResponse, + ); + await mockScorecardEntitiesDrillDown( + page, + githubEntitiesDrillDownWithCalculationErrorsResponse, + 'github.openPRs', + ); + + const entitiesResponse = page.waitForResponse( + res => + res + .url() + .includes( + '/api/scorecard/metrics/github.openPRs/catalog/aggregations/entities', + ) && res.status() === 200, + ); + + await page.goto( + `/scorecard/aggregations/${aggregationMetadata.id}/metrics/github.openPRs`, + ); + await entitiesResponse; + + await scorecardDrillDownPage.expectOnPage('github.openPRs', { + aggregationId: aggregationMetadata.id, + }); + await scorecardDrillDownPage.expectDrillDownCalculationErrorWarningIcon(); + await scorecardDrillDownPage.verifyEntitiesTableCalculationErrorTooltip(); }); test('Jira scorecard: tooltips, entity drill-down, and metric sort', async () => { diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/homepageWidgetUtils.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/homepageWidgetUtils.ts index 5f73d070892..a63a2e7d082 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/homepageWidgetUtils.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/homepageWidgetUtils.ts @@ -23,6 +23,14 @@ import { import { mockApiResponse, waitForAggregationResponse } from './apiUtils'; import { mockAggregationNoDataFound } from './mockHomepageAggregations'; +type AggregatedCardWidgetKey = keyof typeof AGGREGATED_CARDS_METRIC_IDS; + +function isAggregatedCardWidgetKey( + key: string, +): key is AggregatedCardWidgetKey { + return key in AGGREGATED_CARDS_WIDGET_TITLES; +} + type SetupHomepageAggregationCardOptions = { aggregationMetadata: { id: string; title: string }; route: string; @@ -38,12 +46,20 @@ async function addWidget(homePage: HomePage, widgetTitle: string) { await homePage.saveChanges(); } -export async function addAggregatedScorecardWidgets(homePage: HomePage) { +export async function addAggregatedScorecardWidgets( + homePage: HomePage, + widgetIds: Partial< + typeof AGGREGATED_CARDS_METRIC_IDS + > = AGGREGATED_CARDS_METRIC_IDS, +) { await homePage.navigateToHome(); await homePage.enterEditMode(); await homePage.clearAllCards(); - for (const instanceId of Object.keys(AGGREGATED_CARDS_METRIC_IDS)) { + for (const instanceId of Object.keys(widgetIds)) { + if (!isAggregatedCardWidgetKey(instanceId)) { + throw new Error(`Unknown homepage scorecard widget id: ${instanceId}`); + } await homePage.addCard(AGGREGATED_CARDS_WIDGET_TITLES[instanceId]); } diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/mockHomepageAggregations.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/mockHomepageAggregations.ts index 4436db50ecc..ae8cc28d74b 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/mockHomepageAggregations.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/mockHomepageAggregations.ts @@ -20,9 +20,11 @@ import { ScorecardRoutes } from '../constants/routes'; import { emptyGithubAggregatedResponse, emptyJiraAggregatedResponse, + emptyLicenseFileExistsAggregatedResponse, emptyOpenPrsWeightedAggregatedResponse, githubAggregatedResponse, jiraAggregatedResponse, + licenseFileExistsKpiMetadataResponse, notAllowedAggregationErrorBody, openIssuesKpiMetadataResponse, openPrsKpiMetadataResponse, @@ -41,6 +43,9 @@ function aggregationMetadataForRequestUrl(url: string): object { if (url.includes('openPrsWeightedKpi')) { return openPrsWeightedKpiMetadataResponse; } + if (url.includes('licenseFileExistsKpi')) { + return licenseFileExistsKpiMetadataResponse; + } if (url.includes('jira.openIssues')) { return jiraAggregatedResponse.metadata; } @@ -80,7 +85,7 @@ export async function mockAggregationNoDataFound(page: Page): Promise { await page.route('**/api/scorecard/aggregations/**', async route => { const url = route.request().url(); - if (url.includes('metadata')) { + if (url.includes('/metadata')) { await route.fulfill({ status: 200, contentType: 'application/json', @@ -98,6 +103,15 @@ export async function mockAggregationNoDataFound(page: Page): Promise { return; } + if (url.includes(AGGREGATED_CARDS_METRIC_IDS.licenseFileExistsKpi)) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(emptyLicenseFileExistsAggregatedResponse), + }); + return; + } + const githubAggregations = [ AGGREGATED_CARDS_METRIC_IDS.githubOpenPrsKpi, AGGREGATED_CARDS_METRIC_IDS.githubMetricId, 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 2254692aaab..d2231c6baad 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts @@ -940,3 +940,107 @@ export const fileCheckScorecardResponse = [ }, }, ]; + +export const licenseFileExistsKpiMetadataResponse = { + title: 'License File Exists KPI', + description: + 'This KPI provides information about whether the license file exists in the repository.', + type: 'boolean', + history: true, + aggregationType: 'statusGrouped', +}; + +const FILECHECK_BOOLEAN_THRESHOLDS = { + rules: [ + { key: 'exist', expression: '==true' }, + { key: 'missing', expression: '==false' }, + ], +}; + +/** Matches `scorecard.aggregationKPIs.licenseFileExistsKpi` in app-config.yaml */ +export const licenseFileExistsAggregatedResponse = { + id: 'filecheck.license', + status: 'success' as const, + metadata: { + ...licenseFileExistsKpiMetadataResponse, + }, + result: { + values: [ + { count: 7, name: 'exist' }, + { count: 3, name: 'missing' }, + ], + total: 10, + timestamp: '2026-01-24T14:10:32.776Z', + thresholds: FILECHECK_BOOLEAN_THRESHOLDS, + entitiesConsidered: 10, + calculationErrorCount: 0, + }, +}; + +export const emptyLicenseFileExistsAggregatedResponse = { + ...licenseFileExistsAggregatedResponse, + result: { + total: 0, + values: [ + { count: 0, name: 'exist' }, + { count: 0, name: 'missing' }, + ], + timestamp: '2026-01-24T14:10:32.858Z', + thresholds: FILECHECK_BOOLEAN_THRESHOLDS, + entitiesConsidered: 0, + calculationErrorCount: 0, + }, +}; + +export const githubEntitiesDrillDownWithCalculationErrorsResponse = { + ...githubEntitiesDrillDownResponse, + entityHealth: { + totalEntities: 10, + calculationErrorCount: 2, + countsArePartial: true, + }, +}; + +export const weightedKpiEntitiesDrillDownResponse = { + metricId: 'github.openPRs', + metricMetadata: { + title: 'GitHub Open PRs KPI (weighted health)', + description: + 'Weighted health score for open PRs by threshold status across your entities.', + type: 'number', + }, + entities: [ + { + entityRef: 'component:default/red-hat-developer-hub', + entityNamespace: 'default', + entityName: 'red-hat-developer-hub', + entityKind: 'Component', + owner: 'group:default/red-hat', + metricValue: 9, + timestamp: '2026-03-12T08:09:29.732Z', + status: 'success', + }, + { + entityRef: 'component:default/all-scorecards-service', + entityNamespace: 'default', + entityName: 'all-scorecards-service', + entityKind: 'Component', + owner: 'user:development/guest', + metricValue: 46, + timestamp: '2026-03-12T08:09:29.663Z', + status: 'warning', + }, + ], + pagination: { + page: 1, + pageSize: 5, + total: 2, + totalPages: 1, + isCapped: false, + }, + entityHealth: { + totalEntities: 2, + calculationErrorCount: 0, + countsArePartial: false, + }, +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableWrapper.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableWrapper.tsx index f1007301be0..f88be52167c 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableWrapper.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableWrapper.tsx @@ -66,7 +66,11 @@ export const EntitiesTableWrapper: FC = ({ cursor: 'pointer', }} > - + )} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx index 1592c0ad882..499e30f7b10 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx @@ -49,7 +49,7 @@ describe('EntitiesTableWrapper', () => { }); it('should not render calculation warning icon by default', () => { - const { container } = render( + render( Content @@ -58,12 +58,12 @@ describe('EntitiesTableWrapper', () => { ); expect( - container.querySelector('.MuiSvgIcon-colorWarning'), + screen.queryByTestId('entities-table-calculation-warning-icon'), ).not.toBeInTheDocument(); }); it('should render calculation warning icon when showCalculationWarning', () => { - const { container } = render( + render( Content @@ -72,7 +72,7 @@ describe('EntitiesTableWrapper', () => { ); expect( - container.querySelector('.MuiSvgIcon-colorWarning'), + screen.getByTestId('entities-table-calculation-warning-icon'), ).toBeInTheDocument(); expect( screen.getByLabelText(