/* * SonarQube * Copyright (C) 2009-2024 SonarSource SA * mailto:info AT sonarsource DOT com * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3 of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { times } from 'lodash'; import selectEvent from 'react-select-event'; import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock'; import ComponentsServiceMock from '../../../api/mocks/ComponentsServiceMock'; import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock'; import { MeasuresServiceMock } from '../../../api/mocks/MeasuresServiceMock'; import { mockComponent } from '../../../helpers/mocks/component'; import { mockMeasure, mockMetric } from '../../../helpers/testMocks'; import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils'; import { byLabelText, byRole, byTestId, byText } from '../../../helpers/testSelector'; import { ComponentContextShape, ComponentQualifier } from '../../../types/component'; import { Feature } from '../../../types/features'; import { MetricKey } from '../../../types/metrics'; import routes from '../routes'; jest.mock('lodash', () => ({ ...jest.requireActual('lodash'), throttle: (fn: (...args: unknown[]) => unknown) => fn, })); jest.mock('../../../api/metrics', () => { const { DEFAULT_METRICS } = jest.requireActual('../../../helpers/mocks/metrics'); const metrics = Object.values(MetricKey).map( (key) => DEFAULT_METRICS[key] ?? mockMetric({ key }), ); return { getAllMetrics: jest.fn().mockResolvedValue(metrics), }; }); const componentsHandler = new ComponentsServiceMock(); const measuresHandler = new MeasuresServiceMock(); const issuesHandler = new IssuesServiceMock(); const branchHandler = new BranchesServiceMock(); afterEach(() => { componentsHandler.reset(); measuresHandler.reset(); issuesHandler.reset(); branchHandler.reset(); }); describe('rendering', () => { it('should correctly render the default overview and navigation', async () => { const { ui, user } = getPageObject(); renderMeasuresApp(); await ui.appLoaded(); // Overview. expect(ui.seeDataAsListLink.get()).toBeInTheDocument(); expect(ui.overviewDomainBtn.get()).toHaveAttribute('aria-current', 'true'); expect(ui.bubbleChart.get()).toBeInTheDocument(); expect(within(ui.bubbleChart.get()).getAllByRole('link')).toHaveLength(8); expect(ui.newCodePeriodTxt.get()).toBeInTheDocument(); // Sidebar. expect(ui.reliabilityDomainBtn.get()).toBeInTheDocument(); expect(ui.securityDomainBtn.get()).toBeInTheDocument(); expect(ui.securityReviewDomainBtn.get()).toBeInTheDocument(); expect(ui.maintainabilityDomainBtn.get()).toBeInTheDocument(); expect(ui.coverageDomainBtn.get()).toBeInTheDocument(); expect(ui.duplicationsDomainBtn.get()).toBeInTheDocument(); expect(ui.sizeDomainBtn.get()).toBeInTheDocument(); expect(ui.complexityDomainBtn.get()).toBeInTheDocument(); expect(ui.issuesDomainBtn.get()).toBeInTheDocument(); // Check one of the domains. await user.click(ui.maintainabilityDomainBtn.get()); [ 'component_measures.metric.new_maintainability_issues.name 5', 'Added Technical Debt work_duration.x_minutes.1', 'Technical Debt Ratio on New Code 1.0%', 'Maintainability Rating on New Code metric.has_rating_X.E', 'component_measures.metric.maintainability_issues.name 2', 'Technical Debt work_duration.x_minutes.1', 'Technical Debt Ratio 1.0%', 'Maintainability Rating metric.has_rating_X.E', 'Effort to Reach Maintainability Rating A work_duration.x_minutes.1', ].forEach((measure) => { expect(ui.measureBtn(measure).get()).toBeInTheDocument(); }); }); it('should correctly revert to old measures when analysis is missing', async () => { measuresHandler.deleteComponentMeasure('foo', MetricKey.maintainability_issues); measuresHandler.deleteComponentMeasure('foo', MetricKey.new_maintainability_issues); const { ui, user } = getPageObject(); renderMeasuresApp(); await ui.appLoaded(); // Check one of the domains. await user.click(ui.maintainabilityDomainBtn.get()); [ 'component_measures.metric.new_code_smells.name 8', 'Added Technical Debt work_duration.x_minutes.1', 'Technical Debt Ratio on New Code 1.0%', 'Maintainability Rating on New Code metric.has_rating_X.E', 'component_measures.metric.code_smells.name 8', 'Technical Debt work_duration.x_minutes.1', 'Technical Debt Ratio 1.0%', 'Maintainability Rating metric.has_rating_X.E', 'Effort to Reach Maintainability Rating A work_duration.x_minutes.1', ].forEach((measure) => { expect(ui.measureBtn(measure).get()).toBeInTheDocument(); }); expect(screen.getByText('overview.missing_project_data.TRK')).toBeInTheDocument(); }); it('should correctly render a list view', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=code_smells&view=list'); await ui.appLoaded(); expect(ui.measuresTable.get()).toBeInTheDocument(); expect(ui.measuresRows.getAll()).toHaveLength(8); }); it('should correctly render a tree view', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=code_smells&view=tree'); await ui.appLoaded(); expect(ui.measuresTable.get()).toBeInTheDocument(); expect(ui.measuresRows.getAll()).toHaveLength(7); }); it('should correctly render a rating treemap view', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=sqale_rating&view=treemap'); await ui.appLoaded(); expect(ui.treeMap.byRole('link').getAll()).toHaveLength(7); expect(ui.treeMapCell(/folderA .+ Maintainability Rating: C/).get()).toBeInTheDocument(); expect(ui.treeMapCell(/test1\.js .+ Maintainability Rating: B/).get()).toBeInTheDocument(); expect(ui.treeMapCell(/index\.tsx .+ Maintainability Rating: A/).get()).toBeInTheDocument(); }); it('should correctly render a percent treemap view', async () => { const { measures } = componentsHandler; measures['foo:folderA'][MetricKey.coverage] = { metric: MetricKey.coverage, value: '74.2', }; measures['foo:test1.js'][MetricKey.coverage] = { metric: MetricKey.coverage, value: undefined, }; measures['foo:index.tsx'][MetricKey.coverage] = { metric: MetricKey.coverage, value: '13.1', }; const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=coverage&view=treemap'); await ui.appLoaded(); expect(ui.treeMap.byRole('link').getAll()).toHaveLength(7); expect(ui.treeMapCell(/folderA .+ Coverage: 74.2%/).get()).toBeInTheDocument(); expect(ui.treeMapCell(/test1\.js .+ Coverage: —/).get()).toBeInTheDocument(); expect(ui.treeMapCell(/index\.tsx .+ Coverage: 13.1%/).get()).toBeInTheDocument(); }); it('should render correctly for an unknown metric', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=unknown'); await ui.appLoaded(); // Fall back to a known metric. expect(screen.getAllByText('Releasability rating').length).toBeGreaterThan(0); }); it('should render issues measures when query by open_issues', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=open_issues'); await ui.appLoaded(); expect(screen.getAllByText('Issues').length).toEqual(1); [ 'component_measures.metric.new_violations.name 1', 'component_measures.metric.violations.name 1', 'component_measures.metric.confirmed_issues.name 1', 'component_measures.metric.accepted_issues.name 1', 'component_measures.metric.new_accepted_issues.name 1', 'component_measures.metric.false_positive_issues.name 1', ].forEach((measure) => { expect(ui.measureBtn(measure).get()).toBeInTheDocument(); }); }); it('should render correctly if there are no measures', async () => { componentsHandler.registerComponentMeasures({}); measuresHandler.registerComponentMeasures({}); const { ui } = getPageObject(); renderMeasuresApp(); await ui.appLoaded(); expect(ui.emptyText.get()).toBeInTheDocument(); }); it('should render correctly if on a pull request and viewing coverage', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=coverage&pullRequest=01'); await ui.appLoaded(); expect(await ui.detailsUnavailableText.find()).toBeInTheDocument(); }); it('should render a warning message if the user does not have access to all components', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=code_smells', { component: mockComponent({ key: 'foo', qualifier: ComponentQualifier.Portfolio, canBrowseAllChildProjects: false, }), }); await ui.appLoaded(); expect( within(ui.noAccessWarning.get()).getByText('component_measures.not_all_measures_are_shown'), ).toBeInTheDocument(); }); it('should correctly render the language distribution', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=ncloc'); await ui.appLoaded(); expect(screen.getByText('10short_number_suffix.k')).toBeInTheDocument(); expect(screen.getByText('java')).toBeInTheDocument(); expect(screen.getByText('5short_number_suffix.k')).toBeInTheDocument(); expect(screen.getByText('javascript')).toBeInTheDocument(); expect(screen.getByText('1short_number_suffix.k')).toBeInTheDocument(); expect(screen.getByText('css')).toBeInTheDocument(); }); it('should only show the best values in list mode', async () => { const tree = componentsHandler.findComponentTree('foo'); /* eslint-disable-next-line jest/no-conditional-in-test */ if (!tree) { throw new Error('Could not find base tree'); } const measures = measuresHandler.getComponentMeasures(); /* eslint-disable-next-line testing-library/no-node-access */ tree.children.push( ...times(100, (n) => ({ component: mockComponent({ key: `foo:file${n}`, name: `file${n}`, qualifier: ComponentQualifier.File, }), ancestors: [tree.component], children: [], })), ); componentsHandler.registerComponentTree(tree); measuresHandler.registerComponentMeasures( times(100, (n) => `foo:file${n}`).reduce((acc, key) => { acc[key] = { [MetricKey.sqale_rating]: mockMeasure({ metric: MetricKey.sqale_rating, value: '1.0', bestValue: true, }), }; return acc; }, measures), ); const { ui, user } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=sqale_rating&view=list'); await ui.appLoaded(); expect(ui.notShowingAllComponentsTxt.get()).toBeInTheDocument(); await user.click(ui.showAllBtn.get()); expect(ui.notShowingAllComponentsTxt.query()).not.toBeInTheDocument(); }); it('should correctly render a link to the activity page', async () => { const { ui, user } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=new_maintainability_issues'); await ui.appLoaded(); expect(ui.goToActivityLink.query()).not.toBeInTheDocument(); await user.click( ui.measureBtn('component_measures.metric.maintainability_issues.name 2').get(), ); expect(ui.goToActivityLink.get()).toHaveAttribute( 'href', '/project/activity?id=foo&graph=custom&custom_metrics=maintainability_issues', ); }); it('should not render View select options for application metrics', async () => { const { ui } = getPageObject(); const app = mockComponent({ key: 'app', qualifier: ComponentQualifier.Application }); const tree = { component: app, children: [], ancestors: [], }; componentsHandler.registerComponentTree(tree, true); measuresHandler.setComponents(tree); renderMeasuresApp('component_measures?id=app&metric=new_code_smells', { component: app }); await ui.appLoaded(); expect(ui.viewSelect.query()).not.toBeInTheDocument(); }); }); describe('navigation', () => { it('should be able to drilldown through the file structure', async () => { const { ui, user } = getPageObject(); renderMeasuresApp(); await ui.appLoaded(); // Drilldown to the file level. await user.click(ui.maintainabilityDomainBtn.get()); await user.click( ui.measureBtn('component_measures.metric.maintainability_issues.name 2').get(), ); expect( within(ui.measuresRow('folderA').get()).getByRole('cell', { name: '2' }), ).toBeInTheDocument(); expect( within(ui.measuresRow('test1.js').get()).getByRole('cell', { name: '2' }), ).toBeInTheDocument(); await user.click(ui.fileLink('folderA').get()); expect( within(ui.measuresRow('out.tsx').get()).getByRole('cell', { name: '2' }), ).toBeInTheDocument(); expect( within(ui.measuresRow('in.tsx').get()).getByRole('cell', { name: '2' }), ).toBeInTheDocument(); await user.click(ui.fileLink('out.tsx').get()); expect((await ui.sourceCode.findAll()).length).toBeGreaterThan(0); // Go back using the breadcrumbs. await user.click(ui.breadcrumbLink('folderA').get()); expect(ui.measuresRow('out.tsx').get()).toBeInTheDocument(); expect(ui.measuresRow('in.tsx').get()).toBeInTheDocument(); }); it('should be able to drilldown thanks to a files list', async () => { const { ui, user } = getPageObject(); renderMeasuresApp(); await ui.appLoaded(); await user.click(ui.maintainabilityDomainBtn.get()); await user.click( ui.measureBtn('component_measures.metric.maintainability_issues.name 2').get(), ); await waitFor(() => ui.changeViewToList()); expect( within(await ui.measuresRow('out.tsx').find()).getByRole('cell', { name: '2' }), ).toBeInTheDocument(); expect( within(ui.measuresRow('test1.js').get()).getByRole('cell', { name: '2' }), ).toBeInTheDocument(); await user.click(ui.fileLink('out.tsx').get()); expect((await ui.sourceCode.findAll()).length).toBeGreaterThan(0); }); it('should be able to drilldown thanks to a tree map', async () => { const { ui, user } = getPageObject(); renderMeasuresApp(); await ui.appLoaded(); await user.click(ui.maintainabilityDomainBtn.get()); await user.click(ui.measureBtn('Maintainability Rating metric.has_rating_X.E').get()); await waitFor(() => ui.changeViewToTreeMap()); expect(await ui.treeMapCell(/folderA/).find()).toBeInTheDocument(); expect(ui.treeMapCell(/test1\.js/).get()).toBeInTheDocument(); await user.click(ui.treeMapCell(/folderA/).get()); expect(ui.treeMapCell(/out\.tsx/).get()).toBeInTheDocument(); expect(ui.treeMapCell(/in\.tsx/).get()).toBeInTheDocument(); await user.click(ui.treeMapCell(/out.tsx/).get()); expect((await ui.sourceCode.findAll()).length).toBeGreaterThan(0); }); it('should be able to drilldown using the keyboard', async () => { const { ui, user } = getPageObject(); renderMeasuresApp(); await ui.appLoaded(); // Drilldown to the file level. await user.click(ui.maintainabilityDomainBtn.get()); await user.click( ui.measureBtn('component_measures.metric.maintainability_issues.name 2').get(), ); await ui.arrowDown(); // Select the 1st element ("folderA") await ui.arrowRight(); // Open "folderA" expect( within(ui.measuresRow('out.tsx').get()).getByRole('cell', { name: '2' }), ).toBeInTheDocument(); expect( within(ui.measuresRow('in.tsx').get()).getByRole('cell', { name: '2' }), ).toBeInTheDocument(); // Move back to project await ui.arrowLeft(); // Close "folderA" expect( within(ui.measuresRow('folderA').get()).getByRole('cell', { name: '2' }), ).toBeInTheDocument(); await ui.arrowRight(); // Open "folderA" await ui.arrowDown(); // Select the 1st element ("out.tsx") await ui.arrowRight(); // Open "out.tsx" expect((await ui.sourceCode.findAll()).length).toBeGreaterThan(0); expect(screen.getAllByText('out.tsx').length).toBeGreaterThan(0); }); }); describe('redirects', () => { it('should redirect old history route', () => { renderMeasuresApp('component_measures/metric/bugs/history?id=foo'); expect( screen.getByText('/project/activity?id=foo&graph=custom&custom_metrics=bugs'), ).toBeInTheDocument(); }); it('should redirect old metric route', async () => { measuresHandler.deleteComponentMeasure('foo', MetricKey.maintainability_issues); measuresHandler.deleteComponentMeasure('foo', MetricKey.new_maintainability_issues); const { ui } = getPageObject(); renderMeasuresApp('component_measures/metric/bugs?id=foo'); await ui.appLoaded(); expect(ui.measureBtn('component_measures.metric.bugs.name 0').get()).toHaveAttribute( 'aria-current', 'true', ); }); it('should redirect old metric route for software qualities', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures/metric/security_issues?id=foo'); await ui.appLoaded(); expect(ui.measureBtn('component_measures.metric.security_issues.name 1').get()).toHaveAttribute( 'aria-current', 'true', ); }); it('should redirect old domain route', async () => { measuresHandler.deleteComponentMeasure('foo', MetricKey.maintainability_issues); measuresHandler.deleteComponentMeasure('foo', MetricKey.new_maintainability_issues); const { ui } = getPageObject(); renderMeasuresApp('component_measures/domain/bugs?id=foo'); await ui.appLoaded(); expect(ui.reliabilityDomainBtn.get()).toHaveAttribute('aria-expanded', 'true'); }); it('should redirect old domain route for software qualities', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures/domain/reliability_issues?id=foo'); await ui.appLoaded(); expect(ui.reliabilityDomainBtn.get()).toHaveAttribute('aria-expanded', 'true'); }); }); it('should allow to load more components', async () => { const tree = componentsHandler.findComponentTree('foo'); /* eslint-disable-next-line jest/no-conditional-in-test */ if (!tree) { throw new Error('Could not find base tree'); } /* eslint-disable-next-line testing-library/no-node-access */ tree.children.push( ...times(1000, (n) => ({ component: mockComponent({ key: `foo:file${n}`, name: `file${n}`, qualifier: ComponentQualifier.File, }), ancestors: [tree.component], children: [], })), ); componentsHandler.registerComponentTree(tree); const { ui, user } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=code_smells&view=list'); await ui.appLoaded(); await user.click(ui.showAllBtn.get()); expect(ui.showingOutOfTxt('500', '1,008').get()).toBeInTheDocument(); await ui.clickLoadMore(); expect(ui.showingOutOfTxt('1,000', '1,008').get()).toBeInTheDocument(); }); function getPageObject() { const user = userEvent.setup(); const selectors = { // Overview seeDataAsListLink: byRole('link', { name: 'component_measures.overview.see_data_as_list' }), bubbleChart: byTestId('bubble-chart'), newCodePeriodTxt: byText('component_measures.leak_legend.new_code'), // Navigation overviewDomainBtn: byRole('button', { name: 'component_measures.overview.project_overview.subnavigation', }), releasabilityDomainBtn: byRole('button', { name: 'Releasability component_measures.domain_subnavigation.Releasability.help', }), reliabilityDomainBtn: byRole('button', { name: 'Reliability component_measures.domain_subnavigation.Reliability.help', }), securityDomainBtn: byRole('button', { name: 'Security component_measures.domain_subnavigation.Security.help', }), securityReviewDomainBtn: byRole('button', { name: 'SecurityReview component_measures.domain_subnavigation.SecurityReview.help', }), maintainabilityDomainBtn: byRole('button', { name: 'Maintainability component_measures.domain_subnavigation.Maintainability.help', }), coverageDomainBtn: byRole('button', { name: 'Coverage component_measures.domain_subnavigation.Coverage.help', }), duplicationsDomainBtn: byRole('button', { name: 'Duplications component_measures.domain_subnavigation.Duplications.help', }), sizeDomainBtn: byRole('button', { name: 'Size component_measures.domain_subnavigation.Size.help', }), complexityDomainBtn: byRole('button', { name: 'Complexity component_measures.domain_subnavigation.Complexity.help', }), issuesDomainBtn: byRole('button', { name: 'Issues component_measures.domain_subnavigation.Issues.help', }), measureBtn: (name: string) => byRole('button', { name }), // Measure content measuresTable: byRole('table'), measuresRows: byRole('row'), measuresRow: (name: string) => byRole('row', { name: new RegExp(name) }), treeMap: byTestId('treemap'), treeMapCells: byRole('link'), treeMapCell: (name: string | RegExp) => byRole('link', { name }), fileLink: (name: string) => byRole('link', { name }), sourceCode: byText('function Test() {}'), notShowingAllComponentsTxt: byText(/component_measures.hidden_best_score_metrics/), // Misc loading: byText('loading'), breadcrumbLink: (name: string) => byRole('link', { name }), viewSelect: byLabelText('component_measures.view_as'), emptyText: byText('component_measures.empty'), detailsUnavailableText: byText('component_measures.details_are_not_available'), noAccessWarning: byText('component_measures.not_all_measures_are_shown'), showingOutOfTxt: (x: string, y: string) => byText(`x_of_y_shown.${x}.${y}`), showAllBtn: byRole('button', { name: 'component_measures.hidden_best_score_metrics_show_label', }), goToActivityLink: byRole('link', { name: 'component_measures.see_metric_history' }), }; const ui = { ...selectors, async appLoaded() { await waitFor(() => { expect(selectors.loading.query()).not.toBeInTheDocument(); }); }, async changeViewToList() { await selectEvent.select(ui.viewSelect.get(), 'component_measures.tab.list'); }, async changeViewToTreeMap() { await selectEvent.select(ui.viewSelect.get(), 'component_measures.tab.treemap'); }, async arrowDown() { await user.keyboard('[ArrowDown]'); }, async arrowRight() { await user.keyboard('[ArrowRight]'); }, async arrowLeft() { await user.keyboard('[ArrowLeft]'); }, async clickLoadMore() { await user.click(screen.getByRole('button', { name: 'show_more' })); }, }; return { ui, user, }; } function renderMeasuresApp(navigateTo?: string, componentContext?: Partial) { return renderAppWithComponentContext( 'component_measures?id=foo', routes, { navigateTo, featureList: [Feature.BranchSupport] }, { component: mockComponent({ key: 'foo' }), ...componentContext }, ); }