diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index 11e87d52e8d..b5dc755c3ad 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -22,6 +22,33 @@ app: - kind: api # e.g. any API entity - type: service # e.g. Component or System with spec.type: service + # Grid layout – enabled with metric grouping + - scorecard-layout:scorecard/scorecard-entity-layout-grid: + disabled: false + config: + groups: + security-vulnerabilities: + title: Security Vulnerabilities + description: Track security issues across your repositories + metrics: + - sonarqube.securityRating + + code-quality: + title: Code Quality + description: Code quality and maintainability metrics + metrics: + - sonarqube.qualityGate + - sonarqube.codeCoverage + - sonarqube.maintainabilityRating + + sonarqube-coverage: + title: SonarQube Coverage + description: SonarQube coverage metrics + metrics: + - sonarqube.openIssues + - sonarqube.securityHotspots + - sonarqube.securityRating + - home-page-layout:home/dynamic-homepage-layout: config: customizable: true diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/ScorecardPage.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/ScorecardPage.ts index a206ddb617e..ad7c790b90c 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/ScorecardPage.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/ScorecardPage.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { Page, expect } from '@playwright/test'; +import { Locator, Page, expect } from '@playwright/test'; import { waitUntilApiCallSucceeds } from '../utils/apiUtils'; -import { ScorecardMessages } from '../utils/translationUtils'; +import { ScorecardMessages, evaluateMessage } from '../utils/translationUtils'; export class ScorecardPage { readonly page: Page; @@ -110,4 +110,58 @@ export class ScorecardPage { return false; } } + + // Metric Group Card helpers + + getGroupCard(title: string): Locator { + return this.page + .locator('[role="article"]') + .filter({ hasText: title }) + .first(); + } + + getBucketTile( + card: Locator, + thresholdKey: 'success' | 'warning' | 'error', + ): Locator { + return card + .locator('[role="button"]') + .filter({ hasText: this.translations.thresholds[thresholdKey] }); + } + + async openDataSourcesDialog(card: Locator): Promise { + await card + .getByLabel(this.translations.metricGroupCard.menuAriaLabel) + .click(); + await this.page + .getByText(this.translations.metricGroupCard.viewDataSources) + .click(); + const dialog = this.page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + return dialog; + } + + async closeDialog(dialog: Locator) { + await dialog.getByText(this.translations.dataSourcesDialog.close).click(); + } + + getDialogTitle(groupTitle: string): string { + return evaluateMessage( + this.translations.dataSourcesDialog.title, + groupTitle, + ); + } + + getFilterPill( + dialog: Locator, + thresholdKey: 'success' | 'warning' | 'error', + ): Locator { + return dialog + .locator('[role="button"][aria-pressed]') + .filter({ hasText: this.translations.thresholds[thresholdKey] }); + } + + getTableRows(dialog: Locator): Locator { + return dialog.locator('tbody [role="row"]'); + } } 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 e1a257d1156..a6938c027db 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts @@ -86,6 +86,7 @@ import { import { installWebpackDevOverlayGuards } from './utils/devOverlays'; test.describe('Scorecard Plugin Tests', () => { + const isNfs = process.env.APP_MODE === 'nfs'; let page: Page; let catalogPage: CatalogPage; let scorecardPage: ScorecardPage; @@ -317,14 +318,10 @@ test.describe('Scorecard Plugin Tests', () => { }); test.describe('SonarQube Entity Scorecards', () => { - test('Verify all SonarQube metrics display correctly', async ({ - browser, - }, testInfo) => { - const sonarqubeMetrics = Object.entries(translations.metric) + test('Verify all SonarQube metrics display correctly', async ({}, testInfo) => { + const sonarqubeMetricTitles = Object.entries(translations.metric) .filter(([key]) => key.startsWith('sonarqube.')) - .map( - ([_key, value]) => value as { title: string; description: string }, - ); + .map(([, value]) => (value as { title: string }).title); await mockSonarqubeScorecardResponse(page, sonarqubeScorecardResponse); @@ -332,12 +329,38 @@ test.describe('Scorecard Plugin Tests', () => { await catalogPage.openComponent('sonarqube-scorecard-only'); await page.getByText('Scorecard', { exact: true }).click(); - for (const sonarqubeMetric of sonarqubeMetrics) { - await expect( - page.getByText(sonarqubeMetric.title, { exact: true }).first(), - ).toBeVisible({ - timeout: 10000, - }); + if (isNfs) { + const groupedMetricIds = new Set([ + 'sonarqube.qualityGate', + 'sonarqube.codeCoverage', + 'sonarqube.maintainabilityRating', + 'sonarqube.openIssues', + 'sonarqube.securityHotspots', + 'sonarqube.securityRating', + ]); + + const ungroupedMetricTitles = Object.entries(translations.metric) + .filter( + ([key]) => + key.startsWith('sonarqube.') && !groupedMetricIds.has(key), + ) + .map(([, value]) => (value as { title: string }).title); + + for (const title of ungroupedMetricTitles) { + await expect( + page.getByText(title, { exact: true }).first(), + ).toBeVisible({ timeout: 10000 }); + } + + await expect(page.getByLabel('Security Vulnerabilities')).toBeVisible(); + await expect(page.getByLabel('Code Quality')).toBeVisible(); + await expect(page.getByLabel('SonarQube Coverage')).toBeVisible(); + } else { + for (const title of sonarqubeMetricTitles) { + await expect( + page.getByText(title, { exact: true }).first(), + ).toBeVisible({ timeout: 10000 }); + } } await runAccessibilityTests(page, testInfo); @@ -351,20 +374,17 @@ test.describe('Scorecard Plugin Tests', () => { await page.getByText('Scorecard', { exact: true }).click(); await expect( - page.getByText(translations.metric['sonarqube.qualityGate'].title), + page.getByText(translations.metric['sonarqube.securityIssues'].title, { + exact: true, + }), ).toBeVisible({ timeout: 10000 }); const expectedValues: Record = { - [translations.metric['sonarqube.openIssues'].title]: '3', - [translations.metric['sonarqube.securityRating'].title]: '1', [translations.metric['sonarqube.securityIssues'].title]: '0', [translations.metric['sonarqube.securityReviewRating'].title]: '1', - [translations.metric['sonarqube.securityHotspots'].title]: '2', [translations.metric['sonarqube.reliabilityRating'].title]: '1', [translations.metric['sonarqube.reliabilityIssues'].title]: '0', - [translations.metric['sonarqube.maintainabilityRating'].title]: '1', [translations.metric['sonarqube.maintainabilityIssues'].title]: '12', - [translations.metric['sonarqube.codeCoverage'].title]: '82.5', [translations.metric['sonarqube.codeDuplications'].title]: '3.2', }; @@ -376,15 +396,36 @@ test.describe('Scorecard Plugin Tests', () => { await expect(card).toContainText(value); } - const qualityGateCard = page - .locator('[role="article"]') - .filter({ - hasText: translations.metric['sonarqube.qualityGate'].title, - }) - .first(); - await expect( - qualityGateCard.getByTestId('CheckCircleOutlineIcon'), - ).toBeVisible(); + if (isNfs) { + await expect(page.getByLabel('Code Quality')).toBeVisible(); + } else { + const legacyOnlyValues: Record = { + [translations.metric['sonarqube.qualityGate'].title]: 'Success', + [translations.metric['sonarqube.openIssues'].title]: '3', + [translations.metric['sonarqube.securityRating'].title]: '1', + [translations.metric['sonarqube.securityHotspots'].title]: '2', + [translations.metric['sonarqube.maintainabilityRating'].title]: '1', + [translations.metric['sonarqube.codeCoverage'].title]: '82.5', + }; + + for (const [title, value] of Object.entries(legacyOnlyValues)) { + const card = page + .locator('[role="article"]') + .filter({ hasText: title }) + .first(); + await expect(card).toContainText(value); + } + + const qualityGateCard = page + .locator('[role="article"]') + .filter({ + hasText: translations.metric['sonarqube.qualityGate'].title, + }) + .first(); + await expect( + qualityGateCard.getByTestId('CheckCircleOutlineIcon'), + ).toBeVisible(); + } }); test('Verify SonarQube quality gate failure state', async () => { @@ -397,19 +438,29 @@ test.describe('Scorecard Plugin Tests', () => { await catalogPage.openComponent('sonarqube-scorecard-only'); await page.getByText('Scorecard', { exact: true }).click(); - await expect( - page.getByText(translations.metric['sonarqube.qualityGate'].title), - ).toBeVisible({ timeout: 10000 }); - - const qualityGateCard = page - .locator('[role="article"]') - .filter({ - hasText: translations.metric['sonarqube.qualityGate'].description, - }) - .first(); - await expect( - qualityGateCard.getByTestId('DangerousOutlinedIcon'), - ).toBeVisible(); + if (isNfs) { + const codeQualityCard = page + .locator('[role="article"]') + .filter({ hasText: 'Code Quality' }) + .first(); + await expect(codeQualityCard).toBeVisible({ timeout: 10000 }); + + const errorBucket = codeQualityCard + .locator('[role="button"]') + .filter({ hasText: translations.thresholds.error }); + await expect(errorBucket).toBeVisible(); + } else { + const qualityGateCard = page + .locator('[role="article"]') + .filter({ + hasText: translations.metric['sonarqube.qualityGate'].title, + }) + .first(); + await expect(qualityGateCard).toBeVisible({ timeout: 10000 }); + await expect( + qualityGateCard.getByTestId('DangerousOutlinedIcon'), + ).toBeVisible(); + } }); test('Verify empty state for sonarqube entity with no metrics', async () => { @@ -1095,4 +1146,150 @@ test.describe('Scorecard Plugin Tests', () => { }); }); }); + + test.describe('Metric Group Cards', () => { + test.skip(!isNfs, 'MetricGroupCard is only available in NFS mode'); + + test.beforeAll(async () => { + await mockSonarqubeScorecardResponse(page, sonarqubeScorecardResponse); + await catalogPage.openCatalog(); + await catalogPage.openComponent('sonarqube-scorecard-only'); + await page.getByText('Scorecard', { exact: true }).click(); + await expect(page.getByLabel('Security Vulnerabilities')).toBeVisible({ + timeout: 15000, + }); + }); + + test('Verify group cards render with titles, descriptions, and bucket tiles', async ({}, testInfo) => { + // Security Vulnerabilities: sonarqube.securityRating(success) = 1 success + const securityCard = scorecardPage.getGroupCard( + 'Security Vulnerabilities', + ); + await expect(page.getByLabel('Security Vulnerabilities')).toBeVisible(); + await expect( + securityCard.getByText( + 'Track security issues across your repositories', + ), + ).toBeVisible(); + await expect( + scorecardPage.getBucketTile(securityCard, 'success'), + ).toContainText('1'); + + // Code Quality: qualityGate(success) + codeCoverage(success) + maintainabilityRating(success) = 3 success + const codeQualityCard = scorecardPage.getGroupCard('Code Quality'); + await expect(page.getByLabel('Code Quality')).toBeVisible(); + await expect( + codeQualityCard.getByText('Code quality and maintainability metrics'), + ).toBeVisible(); + await expect( + scorecardPage.getBucketTile(codeQualityCard, 'success'), + ).toContainText('3'); + + // SonarQube Coverage: openIssues(warning) + securityHotspots(warning) + securityRating(success) + const coverageCard = scorecardPage.getGroupCard('SonarQube Coverage'); + await expect(page.getByLabel('SonarQube Coverage')).toBeVisible(); + await expect( + coverageCard.getByText('SonarQube coverage metrics'), + ).toBeVisible(); + await expect( + scorecardPage.getBucketTile(coverageCard, 'warning'), + ).toContainText('2'); + await expect( + scorecardPage.getBucketTile(coverageCard, 'success'), + ).toContainText('1'); + + await runAccessibilityTests(page, testInfo); + }); + + test('Verify data sources dialog opens from menu', async ({}, testInfo) => { + const securityCard = scorecardPage.getGroupCard( + 'Security Vulnerabilities', + ); + const dialog = await scorecardPage.openDataSourcesDialog(securityCard); + + await expect(dialog).toContainText( + scorecardPage.getDialogTitle('Security Vulnerabilities'), + ); + + await expect( + dialog.getByText(translations.dataSourcesDialog.columns.plugin), + ).toBeVisible(); + await expect( + dialog.getByText(translations.dataSourcesDialog.columns.check), + ).toBeVisible(); + await expect( + dialog.getByText(translations.dataSourcesDialog.columns.value, { + exact: true, + }), + ).toBeVisible(); + await expect( + dialog.getByText(translations.dataSourcesDialog.columns.status), + ).toBeVisible(); + await expect( + dialog.getByText(translations.dataSourcesDialog.columns.lastSynced), + ).toBeVisible(); + + await runAccessibilityTests(page, testInfo, undefined, { + disableRules: ['color-contrast'], + }); + + await scorecardPage.closeDialog(dialog); + await expect(dialog).not.toBeVisible(); + }); + + test('Verify filter pills filter data sources by threshold in SonarQube Coverage', async () => { + const coverageCard = scorecardPage.getGroupCard('SonarQube Coverage'); + const dialog = await scorecardPage.openDataSourcesDialog(coverageCard); + const tableRows = scorecardPage.getTableRows(dialog); + + // SonarQube Coverage has 3 metrics: openIssues(warning), securityHotspots(warning), securityRating(success) + await expect(tableRows).toHaveCount(3); + + // Click "Warning" filter → should show only the 2 warning metrics + const warningPill = scorecardPage.getFilterPill(dialog, 'warning'); + await warningPill.click(); + await expect(warningPill).toHaveAttribute('aria-pressed', 'true'); + await expect(tableRows).toHaveCount(2); + for (const row of await tableRows.all()) { + await expect(row).toContainText(translations.thresholds.warning); + } + + // Deactivate "Warning" → all 3 rows visible again + await warningPill.click(); + await expect(warningPill).toHaveAttribute('aria-pressed', 'false'); + await expect(tableRows).toHaveCount(3); + + // Click "Success" filter → should show only 1 success metric + const successPill = scorecardPage.getFilterPill(dialog, 'success'); + await successPill.click(); + await expect(successPill).toHaveAttribute('aria-pressed', 'true'); + await expect(tableRows).toHaveCount(1); + for (const row of await tableRows.all()) { + await expect(row).toContainText(translations.thresholds.success); + } + + await scorecardPage.closeDialog(dialog); + }); + + test('Verify clicking bucket tile opens dialog with filter pre-applied', async () => { + const coverageCard = scorecardPage.getGroupCard('SonarQube Coverage'); + + // Click the "Warning" bucket tile (count = 2) + await scorecardPage.getBucketTile(coverageCard, 'warning').click(); + + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Filter is pre-applied from the tile click + const activePill = dialog.locator('[role="button"][aria-pressed="true"]'); + await expect(activePill).toBeVisible(); + const tableRows = scorecardPage.getTableRows(dialog); + await expect(tableRows).toHaveCount(2); + for (const row of await tableRows.all()) { + await expect(row).toContainText(translations.thresholds.warning); + } + + await scorecardPage.closeDialog(dialog); + }); + }); }); diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/accessibility.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/accessibility.ts index bb6865f769b..a48662f27bd 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/accessibility.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/accessibility.ts @@ -23,6 +23,7 @@ export async function runAccessibilityTests( attachName = 'accessibility-scan-results.json', options?: { includeSelectors?: string[]; + disableRules?: string[]; }, ) { let axeBuilder = new AxeBuilder({ page }).withTags([ @@ -34,6 +35,9 @@ export async function runAccessibilityTests( for (const selector of options?.includeSelectors ?? []) { axeBuilder = axeBuilder.include(selector); } + if (options?.disableRules?.length) { + axeBuilder = axeBuilder.disableRules(options.disableRules); + } const accessibilityScanResults = await axeBuilder.analyze(); await testInfo.attach(attachName, { diff --git a/workspaces/scorecard/tsconfig.json b/workspaces/scorecard/tsconfig.json index d346b34ac55..f70927eb8d1 100644 --- a/workspaces/scorecard/tsconfig.json +++ b/workspaces/scorecard/tsconfig.json @@ -4,7 +4,8 @@ "packages/*/src", "plugins/*/src", "plugins/*/dev", - "plugins/*/migrations" + "plugins/*/migrations", + "packages/*/e2e-tests" ], "files": ["node_modules/@backstage/cli/asset-types/asset-types.d.ts"], "exclude": ["node_modules"],