diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index 8e5e5a40f2d..f41c2fc2f09 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-nfs.test.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard-nfs.test.ts new file mode 100644 index 00000000000..362ef62c032 --- /dev/null +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard-nfs.test.ts @@ -0,0 +1,238 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, Page } from '@playwright/test'; +import { mockSonarqubeScorecardResponse } from './utils/apiUtils'; +import { CatalogPage } from './pages/CatalogPage'; +import { ScorecardPage } from './pages/ScorecardPage'; +import { getTranslations, ScorecardMessages } from './utils/translationUtils'; +import { + sonarqubeScorecardResponse, + sonarqubeFailedQualityGateResponse, +} from './utils/scorecardResponseUtils'; +import { runAccessibilityTests } from './utils/accessibility'; +import { installWebpackDevOverlayGuards } from './utils/devOverlays'; + +test.describe('Metric Group Cards', () => { + let page: Page; + let catalogPage: CatalogPage; + let scorecardPage: ScorecardPage; + let translations: ScorecardMessages; + + test.beforeAll(async ({ browser }, testInfo) => { + const locale = (testInfo.project.use.locale as string | undefined) ?? 'en'; + const context = await browser.newContext({ locale }); + await context.addInitScript(installWebpackDevOverlayGuards); + page = await context.newPage(); + const currentLocale = await page.evaluate( + () => globalThis.navigator.language, + ); + translations = getTranslations(currentLocale); + + catalogPage = new CatalogPage(page); + scorecardPage = new ScorecardPage(page, translations); + + await catalogPage.loginAndSetLocale(currentLocale); + + 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.afterAll(async () => { + await page?.context()?.close(); + }); + + test('Verify group cards render with titles, descriptions, and bucket tiles', async ({}, testInfo) => { + 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'); + + 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'); + + 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'), + ); + + const expectedColumns = [ + translations.dataSourcesDialog.columns.plugin, + translations.dataSourcesDialog.columns.check, + translations.dataSourcesDialog.columns.value, + translations.dataSourcesDialog.columns.status, + translations.dataSourcesDialog.columns.lastSynced, + ]; + for (const column of expectedColumns) { + await expect(dialog.getByText(column, { exact: true })).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); + + await expect(tableRows).toHaveCount(3); + + 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); + } + + await warningPill.click(); + await expect(warningPill).toHaveAttribute('aria-pressed', 'false'); + await expect(tableRows).toHaveCount(3); + + 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'); + + await scorecardPage.getBucketTile(coverageCard, 'warning').click(); + + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + 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); + }); + + test('Verify ungrouped metrics render as individual cards alongside group cards', async ({}, testInfo) => { + await mockSonarqubeScorecardResponse(page, sonarqubeScorecardResponse); + await catalogPage.openCatalog(); + await catalogPage.openComponent('sonarqube-scorecard-only'); + await page.getByText('Scorecard', { exact: true }).click(); + + 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(); + + await runAccessibilityTests(page, testInfo); + }); + + test('Verify grouped metric values are reflected in Code Quality group card', 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('Code Quality')).toBeVisible({ + timeout: 10000, + }); + }); + + test('Verify quality gate failure shows error bucket on Code Quality group card', async () => { + await mockSonarqubeScorecardResponse( + page, + sonarqubeFailedQualityGateResponse, + ); + await catalogPage.openCatalog(); + await catalogPage.openComponent('sonarqube-scorecard-only'); + await page.getByText('Scorecard', { exact: true }).click(); + + 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(); + }); +}); 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..1109cbe0950 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts @@ -317,14 +317,15 @@ 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.skip( + process.env.APP_MODE === 'nfs', + 'Legacy-only: NFS uses grouped metric cards', + ); + + 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 +333,10 @@ test.describe('Scorecard Plugin Tests', () => { await catalogPage.openComponent('sonarqube-scorecard-only'); await page.getByText('Scorecard', { exact: true }).click(); - for (const sonarqubeMetric of sonarqubeMetrics) { + for (const title of sonarqubeMetricTitles) { await expect( - page.getByText(sonarqubeMetric.title, { exact: true }).first(), - ).toBeVisible({ - timeout: 10000, - }); + page.getByText(title, { exact: true }).first(), + ).toBeVisible({ timeout: 10000 }); } await runAccessibilityTests(page, testInfo); @@ -351,21 +350,25 @@ 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', + [translations.metric['sonarqube.qualityGate'].title]: + translations.thresholds.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(expectedValues)) { @@ -397,16 +400,13 @@ 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, + hasText: translations.metric['sonarqube.qualityGate'].title, }) .first(); + await expect(qualityGateCard).toBeVisible({ timeout: 10000 }); await expect( qualityGateCard.getByTestId('DangerousOutlinedIcon'), ).toBeVisible(); 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/playwright.config.ts b/workspaces/scorecard/playwright.config.ts index 5605971e90e..747f566c44d 100644 --- a/workspaces/scorecard/playwright.config.ts +++ b/workspaces/scorecard/playwright.config.ts @@ -59,6 +59,10 @@ export default defineConfig({ projects: LOCALES.map(locale => ({ name: locale, testDir: 'packages/app-legacy/e2e-tests', + testMatch: + appMode === 'nfs' + ? ['scorecard.test.ts', 'scorecard-nfs.test.ts'] + : 'scorecard.test.ts', use: { channel: 'chrome' as const, locale,