diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/BillingOptions/BillingOptions.test.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/BillingOptions/BillingOptions.test.tsx index 0610562ea0..8f5500bfae 100644 --- a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/BillingOptions/BillingOptions.test.tsx +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/BillingOptions/BillingOptions.test.tsx @@ -61,7 +61,7 @@ const availablePlans = [ const mockPlanDataResponse = { baseUnitPrice: 10, benefits: [], - billingRate: 'monthly', + billingRate: 'annual', marketingName: 'Sentry', monthlyUploadLimit: 250, value: 'test-plan', diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsPage.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsPage.test.tsx new file mode 100644 index 0000000000..2b698e06ad --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsPage.test.tsx @@ -0,0 +1,84 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { PropsWithChildren, Suspense } from 'react' +import { MemoryRouter, Route } from 'react-router-dom' + +import FailedTestsPage from './FailedTestsPage' + +vi.mock('./SelectorSection/SelectorSection', () => ({ + default: () => 'Selector Section', +})) +vi.mock('./MetricsSection/MetricsSection', () => ({ + default: () => 'Metrics Section', +})) +vi.mock('./FailedTestsTable/FailedTestsTable', () => ({ + default: () => 'Failed Tests Table', +})) + +const server = setupServer() +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + suspense: false, + }, + }, +}) + +const wrapper: (initialEntries?: string) => React.FC = + (initialEntries = '/gh/codecov/cool-repo/tests') => + ({ children }) => ( + + + + {children} + + + + ) + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +describe('FailedTestsPage', () => { + function setup() { + server.use( + graphql.query('GetRepoOverview', (info) => { + return HttpResponse.json({}) + }) + ) + } + + it('renders sub-components', () => { + setup() + render(, { wrapper: wrapper() }) + + const selectorSection = screen.getByText(/Selector Section/) + const metricSection = screen.getByText(/Metrics Section/) + const table = screen.getByText(/Failed Tests Table/) + + expect(selectorSection).toBeInTheDocument() + expect(metricSection).toBeInTheDocument() + expect(table).toBeInTheDocument() + }) +}) diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsPage.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsPage.tsx new file mode 100644 index 0000000000..678be1c9c9 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsPage.tsx @@ -0,0 +1,15 @@ +import FailedTestsTable from './FailedTestsTable' +import { MetricsSection } from './MetricsSection' +import { SelectorSection } from './SelectorSection' + +function FailedTestsPage() { + return ( +
+ + + +
+ ) +} + +export default FailedTestsPage diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsTable/FailedTestsTable.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsTable/FailedTestsTable.test.tsx similarity index 66% rename from src/pages/RepoPage/FailedTestsTab/FailedTestsTable/FailedTestsTable.test.tsx rename to src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsTable/FailedTestsTable.test.tsx index 382e966cd9..2b6d999691 100644 --- a/src/pages/RepoPage/FailedTestsTab/FailedTestsTable/FailedTestsTable.test.tsx +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsTable/FailedTestsTable.test.tsx @@ -13,14 +13,26 @@ import { MemoryRouter, Route } from 'react-router-dom' import FailedTestsTable from './FailedTestsTable' -import { OrderingDirection, OrderingParameter } from '../hooks' +import { + OrderingDirection, + OrderingParameter, +} from '../hooks/useInfiniteTestResults' + +vi.mock('../TableHeader/TableHeader', () => ({ + default: () => 'Table Header', +})) const node1 = { updatedAt: '2023-01-01T00:00:00Z', name: 'test-1', commitsFailed: 1, failureRate: 0.1, + flakeRate: 0.0, avgDuration: 10, + totalFailCount: 5, + totalFlakyFailCount: 14, + totalPassCount: 6, + totalSkipCount: 7, } const node2 = { @@ -28,7 +40,12 @@ const node2 = { name: 'test-2', commitsFailed: 2, failureRate: 0.2, + flakeRate: 0.2, avgDuration: 20, + totalFailCount: 8, + totalFlakyFailCount: 15, + totalPassCount: 9, + totalSkipCount: 10, } const node3 = { @@ -36,7 +53,12 @@ const node3 = { name: 'test-3', commitsFailed: 3, failureRate: 0.3, + flakeRate: 0.1, avgDuration: 30, + totalFailCount: 11, + totalFlakyFailCount: 16, + totalPassCount: 12, + totalSkipCount: 13, } const server = setupServer() @@ -61,6 +83,24 @@ const wrapper = let consoleError: any let consoleWarn: any +class ResizeObserverMock { + [x: string]: any + constructor(cb: any) { + this.cb = cb + } + observe() { + this.cb([{ borderBoxSize: { inlineSize: 0, blockSize: 0 } }]) + } + unobserve() { + // do nothing + } + disconnect() { + // do nothing + } +} + +global.window.ResizeObserver = ResizeObserverMock + beforeAll(() => { server.listen() // Mock console.error and console.warn @@ -85,11 +125,23 @@ afterAll(() => { interface SetupArgs { noEntries?: boolean bundleAnalysisEnabled?: boolean + planValue?: string + isPrivate?: boolean } describe('FailedTestsTable', () => { - function setup({ noEntries = false }: SetupArgs) { - const queryClient = new QueryClient() + function setup({ + noEntries = false, + planValue = 'users-enterprisem', + isPrivate = false, + }: SetupArgs) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + suspense: false, + }, + }, + }) const user = userEvent.setup({ delay: null }) const mockVariables = vi.fn() @@ -102,8 +154,13 @@ describe('FailedTestsTable', () => { return HttpResponse.json({ data: { owner: { + plan: { + value: planValue, + }, repository: { __typename: 'Repository', + private: isPrivate, + defaultBranch: 'main', testAnalytics: { testResults: { edges: [], @@ -111,6 +168,7 @@ describe('FailedTestsTable', () => { hasNextPage: false, endCursor: null, }, + totalCount: 1234, }, }, }, @@ -121,8 +179,13 @@ describe('FailedTestsTable', () => { const dataReturned = { owner: { + plan: { + value: planValue, + }, repository: { __typename: 'Repository', + private: isPrivate, + defaultBranch: 'main', testAnalytics: { testResults: { edges: info.variables.after @@ -134,6 +197,7 @@ describe('FailedTestsTable', () => { ? 'aa' : 'MjAyMC0wOC0xMSAxNzozMDowMiswMDowMHwxMDA=', }, + totalCount: 1234, }, }, }, @@ -147,6 +211,56 @@ describe('FailedTestsTable', () => { } describe('renders table headers', () => { + describe('when repo is private', () => { + describe('when plan is team plan', () => { + it('does not render flake rate column', async () => { + const { queryClient } = setup({ + planValue: 'users-teamm', + isPrivate: true, + }) + render(, { + wrapper: wrapper(queryClient), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeRateColumn = screen.queryByText('Flake rate') + expect(flakeRateColumn).not.toBeInTheDocument() + }) + }) + + describe('when plan is free', () => { + it('does not render flake rate column', async () => { + const { queryClient } = setup({ + planValue: 'users-free', + isPrivate: true, + }) + render(, { + wrapper: wrapper(queryClient), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeRateColumn = screen.queryByText('Flake rate') + expect(flakeRateColumn).not.toBeInTheDocument() + }) + }) + + describe('when not on default branch', () => { + it('does not render flake rate column', async () => { + const { queryClient } = setup({}) + render(, { + wrapper: wrapper(queryClient, ['/gh/codecov/repo/tests/lol']), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeRateColumn = screen.queryByText('Flake rate') + expect(flakeRateColumn).not.toBeInTheDocument() + }) + }) + }) + it('renders each column name', async () => { const { queryClient } = setup({}) render(, { @@ -156,18 +270,31 @@ describe('FailedTestsTable', () => { const nameColumn = await screen.findByText('Test name') expect(nameColumn).toBeInTheDocument() - const durationColumn = await screen.findByText('Average duration') + const durationColumn = await screen.findByText('Avg duration') expect(durationColumn).toBeInTheDocument() const failureRateColumn = await screen.findByText('Failure rate') expect(failureRateColumn).toBeInTheDocument() + const flakeRateColumn = await screen.findByText('Flake rate') + expect(flakeRateColumn).toBeInTheDocument() + const commitFailedColumn = await screen.findByText('Commits failed') expect(commitFailedColumn).toBeInTheDocument() const lastRunColumn = await screen.findByText('Last run') expect(lastRunColumn).toBeInTheDocument() }) + + it('renders table header', async () => { + const { queryClient } = setup({}) + render(, { + wrapper: wrapper(queryClient), + }) + + const tableHeader = await screen.findByText('Table Header') + expect(tableHeader).toBeInTheDocument() + }) }) describe('renders table body', () => { @@ -189,12 +316,34 @@ describe('FailedTestsTable', () => { const failureRateColumn = await screen.findByText('10.00%') expect(failureRateColumn).toBeInTheDocument() + const flakeRateColumn = await screen.findByText('0%') + expect(flakeRateColumn).toBeInTheDocument() + const commitFailedColumn = await screen.findByText('1') expect(commitFailedColumn).toBeInTheDocument() const lastRunColumn = await screen.findAllByText('over 1 year ago') expect(lastRunColumn.length).toBeGreaterThan(0) }) + + it('shows additional info when hovering flake rate', async () => { + const { queryClient, user } = setup({}) + render(, { + wrapper: wrapper(queryClient), + }) + + const loading = await screen.findByText('Loading') + mockIsIntersecting(loading, false) + + const flakeRateColumn = await screen.findByText('0%') + expect(flakeRateColumn).toBeInTheDocument() + + await user.hover(flakeRateColumn) + + const hoverObj = await screen.findAllByText(/6 Passed, 5 Failed /) + + expect(hoverObj.length).toBeGreaterThan(0) + }) }) describe('no data is returned', () => { @@ -217,7 +366,7 @@ describe('FailedTestsTable', () => { wrapper: wrapper(queryClient), }) - const durationColumn = await screen.findByText('Average duration') + const durationColumn = await screen.findByText('Avg duration') await user.click(durationColumn) await waitFor(() => { @@ -279,6 +428,40 @@ describe('FailedTestsTable', () => { }) }) + it('can sort on flake rate column', async () => { + const { queryClient, user, mockVariables } = setup({ noEntries: true }) + render(, { + wrapper: wrapper(queryClient), + }) + + const flakeRateColumn = await screen.findByText('Flake rate') + await user.click(flakeRateColumn) + + await waitFor(() => { + expect(mockVariables).toHaveBeenCalledWith( + expect.objectContaining({ + ordering: { + direction: OrderingDirection.DESC, + parameter: OrderingParameter.FLAKE_RATE, + }, + }) + ) + }) + + await user.click(flakeRateColumn) + + await waitFor(() => { + expect(mockVariables).toHaveBeenCalledWith( + expect.objectContaining({ + ordering: { + direction: OrderingDirection.ASC, + parameter: OrderingParameter.FLAKE_RATE, + }, + }) + ) + }) + }) + it('can sort on commits failed column', async () => { const { queryClient, user, mockVariables } = setup({ noEntries: true }) render(, { @@ -388,9 +571,7 @@ describe('FailedTestsTable', () => { wrapper: wrapper(queryClient, ['/gh/codecov/repo/tests/main']), }) - const content = await screen.findByText( - 'No test results found for this branch' - ) + const content = await screen.findByText('No test results found') expect(content).toBeInTheDocument() }) }) diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsTable/FailedTestsTable.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsTable/FailedTestsTable.tsx similarity index 53% rename from src/pages/RepoPage/FailedTestsTab/FailedTestsTable/FailedTestsTable.tsx rename to src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsTable/FailedTestsTable.tsx index 67afa1d539..21efbcdbf0 100644 --- a/src/pages/RepoPage/FailedTestsTab/FailedTestsTable/FailedTestsTable.tsx +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsTable/FailedTestsTable.tsx @@ -1,4 +1,5 @@ import { + CellContext, createColumnHelper, flexRender, getCoreRowModel, @@ -8,19 +9,29 @@ import { } from '@tanstack/react-table' import cs from 'classnames' import isEmpty from 'lodash/isEmpty' +import qs from 'qs' import { useEffect, useMemo, useState } from 'react' import { useInView } from 'react-intersection-observer' -import { useParams } from 'react-router-dom' +import { useLocation, useParams } from 'react-router-dom' +import { MeasurementInterval } from 'pages/RepoPage/shared/constants' +import { isFreePlan, isTeamPlan } from 'shared/utils/billing' import { formatTimeToNow } from 'shared/utils/dates' import Icon from 'ui/Icon' import Spinner from 'ui/Spinner' +import { Tooltip } from 'ui/Tooltip' import { OrderingDirection, OrderingParameter, useInfiniteTestResults, -} from '../hooks' +} from '../hooks/useInfiniteTestResults' +import { TestResultsFilterParameterType } from '../hooks/useInfiniteTestResults/useInfiniteTestResults' +import { + historicalTrendToCopy, + TooltipWithIcon, +} from '../MetricsSection/MetricsSection' +import { TableHeader } from '../TableHeader' const getDecodedBranch = (branch?: string) => !!branch ? decodeURIComponent(branch) : undefined @@ -67,6 +78,10 @@ export function getSortingOption( parameter = OrderingParameter.FAILURE_RATE } + if (state.id === 'flakeRate') { + parameter = OrderingParameter.FLAKE_RATE + } + if (state.id === 'commitsFailed') { parameter = OrderingParameter.COMMITS_WHERE_FAIL } @@ -84,44 +99,74 @@ export function getSortingOption( const isNumericValue = (value: string) => value === 'avgDuration' || value === 'failureRate' || - value === 'commitsFailed' + value === 'commitsFailed' || + value === 'flakeRate' interface FailedTestsColumns { name: string avgDuration: number | null failureRate: number | null + flakeRate?: React.ReactNode commitsFailed: number | null updatedAt: string } const columnHelper = createColumnHelper() -const columns = [ - columnHelper.accessor('name', { - header: () => 'Test name', - cell: (info) => info.renderValue(), - }), - columnHelper.accessor('avgDuration', { - header: () => 'Average duration', - cell: (info) => `${(info.renderValue() ?? 0).toFixed(3)}s`, - }), - columnHelper.accessor('failureRate', { - header: () => 'Failure rate', - cell: (info) => { - const value = (info.renderValue() ?? 0) * 100 - const isInt = Number.isInteger(info.renderValue()) - return isInt ? `${value}%` : `${value.toFixed(2)}%` - }, - }), - columnHelper.accessor('commitsFailed', { - header: () => 'Commits failed', - cell: (info) => (info.renderValue() ? info.renderValue() : 0), - }), - columnHelper.accessor('updatedAt', { - header: () => 'Last run', - cell: (info) => formatTimeToNow(info.renderValue()), - }), -] +const getColumns = ({ + hideFlakeRate, + interval, +}: { + hideFlakeRate: boolean + interval?: MeasurementInterval +}) => { + const baseColumns = [ + columnHelper.accessor('name', { + header: () => 'Test name', + cell: (info) => info.renderValue(), + }), + columnHelper.accessor('avgDuration', { + header: () => 'Avg duration', + cell: (info) => `${(info.renderValue() ?? 0).toFixed(3)}s`, + }), + columnHelper.accessor('failureRate', { + header: () => 'Failure rate', + cell: (info) => { + const value = (info.renderValue() ?? 0) * 100 + const isInt = Number.isInteger(info.renderValue()) + return isInt ? `${value}%` : `${value.toFixed(2)}%` + }, + }), + columnHelper.accessor('commitsFailed', { + header: () => 'Commits failed', + cell: (info) => (info.renderValue() ? info.renderValue() : 0), + }), + columnHelper.accessor('updatedAt', { + header: () => 'Last run', + cell: (info) => formatTimeToNow(info.renderValue()), + }), + ] + + if (!hideFlakeRate) { + baseColumns.splice(3, 0, { + accessorKey: 'flakeRate', + header: () => ( +
+ Flake rate + + Shows how often a flake occurs by tracking how many times a test + goes from fail to pass or pass to fail on a given branch and commit + within the last {historicalTrendToCopy(interval)}. + +
+ ), + cell: (info: CellContext) => + info.renderValue(), + }) + } + + return baseColumns +} interface URLParams { provider: string @@ -139,6 +184,24 @@ const FailedTestsTable = () => { }, ]) const { provider, owner, repo, branch } = useParams() + const location = useLocation() + const queryParams = qs.parse(location.search, { + ignoreQueryPrefix: true, + depth: 1, + }) + + let flags = undefined + if (Array.isArray(queryParams?.flags) && queryParams?.flags?.length > 0) { + flags = queryParams?.flags + } + + let testSuites = undefined + if ( + Array.isArray(queryParams?.testSuites) && + queryParams?.testSuites?.length > 0 + ) { + testSuites = queryParams?.testSuites + } const { data: testData, @@ -153,26 +216,84 @@ const FailedTestsTable = () => { ordering: getSortingOption(sorting), filters: { branch: branch ? getDecodedBranch(branch) : undefined, + flags: flags as string[], + // eslint-disable-next-line camelcase + test_suites: testSuites as string[], + parameter: queryParams?.parameter as TestResultsFilterParameterType, + interval: queryParams?.historicalTrend as MeasurementInterval, + term: queryParams?.term as string, }, opts: { suspense: false, }, }) + const isDefaultBranch = testData?.defaultBranch === branch + const isTeamOrFreePlan = + isTeamPlan(testData?.plan) || isFreePlan(testData?.plan) + // Only show flake rate column when on default branch for pro / enterprise plans or public repos + const hideFlakeRate = + (isTeamOrFreePlan && testData?.private) || (!!branch && !isDefaultBranch) + const tableData = useMemo(() => { - return testData?.testResults - }, [testData]) + if (!testData?.testResults) return [] + + return ( + testData.testResults.map((result) => { + const value = (result.flakeRate ?? 0) * 100 + const isFlakeInt = Number.isInteger(value) + + const FlakeRateContent = ( + + + + {isFlakeInt ? `${value}%` : `${value.toFixed(2)}%`} + + + + {result.totalPassCount} Passed, {result.totalFailCount} Failed + ({result.totalFlakyFailCount} Flaky), {result.totalSkipCount}{' '} + Skipped + + + + + + ) + + return { + name: result.name, + avgDuration: result.avgDuration, + failureRate: result.failureRate, + flakeRate: FlakeRateContent, + commitsFailed: result.commitsFailed, + updatedAt: result.updatedAt, + } + }) ?? [] + ) + }, [testData?.testResults]) + + const columns = useMemo( + () => + getColumns({ + hideFlakeRate, + interval: queryParams?.historicalTrend as MeasurementInterval, + }), + [hideFlakeRate, queryParams?.historicalTrend] + ) const table = useReactTable({ columns, - data: tableData ?? [], + data: tableData, state: { sorting, }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), - // debugAll: true, }) useEffect(() => { @@ -182,11 +303,24 @@ const FailedTestsTable = () => { }, [fetchNextPage, inView, hasNextPage]) if (isEmpty(testData?.testResults) && !isLoading && !!branch) { - return
No test results found for this branch
+ return ( +
+ +
+

No test results found

+
+ ) } return ( <> +
diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsTable/index.ts b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsTable/index.ts similarity index 100% rename from src/pages/RepoPage/FailedTestsTab/FailedTestsTable/index.ts rename to src/pages/RepoPage/FailedTestsTab/FailedTestsPage/FailedTestsTable/index.ts diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/MetricsSection/MetricsSection.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/MetricsSection/MetricsSection.test.tsx new file mode 100644 index 0000000000..ce3a2e3574 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/MetricsSection/MetricsSection.test.tsx @@ -0,0 +1,524 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { PropsWithChildren, Suspense } from 'react' +import { MemoryRouter, Route, useLocation } from 'react-router-dom' + +import MetricsSection, { historicalTrendToCopy } from './MetricsSection' + +const mockAggResponse = ( + planValue = 'users-enterprisem', + isPrivate = false +) => ({ + owner: { + plan: { + value: planValue, + }, + repository: { + __typename: 'Repository', + defaultBranch: 'main', + private: isPrivate, + testAnalytics: { + testResultsAggregates: { + totalDuration: 1490, + totalDurationPercentChange: 25.0, + slowestTestsDuration: 111.11, + slowestTestsDurationPercentChange: 0.0, + totalSlowTests: 12, + totalSlowTestsPercentChange: 15.1, + totalFails: 1, + totalFailsPercentChange: 100.0, + totalSkips: 20, + totalSkipsPercentChange: 0.0, + }, + }, + }, + }, +}) + +const mockFlakeAggResponse = { + owner: { + repository: { + __typename: 'Repository', + testAnalytics: { + flakeAggregates: { + flakeCount: 88, + flakeCountPercentChange: 10.0, + flakeRate: 8, + flakeRatePercentChange: 5.0, + }, + }, + }, + }, +} + +const server = setupServer() +const queryClient = new QueryClient({ + defaultOptions: { queries: { suspense: true, retry: false } }, +}) + +let testLocation: ReturnType +const wrapper: (initialEntries?: string) => React.FC = + (initialEntries = '/gh/codecov/cool-repo/tests') => + ({ children }) => ( + + + + {children} + + { + testLocation = location + return null + }} + /> + + + ) + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + server.resetHandlers() + queryClient.clear() +}) + +afterAll(() => { + server.close() +}) + +describe('MetricsSection', () => { + function setup(planValue = 'users-enterprisem', isPrivate = false) { + const user = userEvent.setup() + + server.use( + graphql.query('GetTestResultsAggregates', (info) => { + return HttpResponse.json({ + data: mockAggResponse(planValue, isPrivate), + }) + }), + graphql.query('GetFlakeAggregates', (info) => { + return HttpResponse.json({ data: mockFlakeAggResponse }) + }) + ) + return { user } + } + + describe('historicalTrendToCopy', () => { + it('returns correct values for intervals', () => { + expect(historicalTrendToCopy()).toBe('30 days') + expect(historicalTrendToCopy('INTERVAL_30_DAY')).toBe('30 days') + expect(historicalTrendToCopy('INTERVAL_7_DAY')).toBe('7 days') + expect(historicalTrendToCopy('INTERVAL_1_DAY')).toBe('1 day') + }) + }) + + describe('when not on default branch', () => { + it('does not render component', () => { + setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/ight'), + }) + + const runEfficiency = screen.queryByText('Improve CI Run Efficiency') + const testPerf = screen.queryByText('Improve Test Performance') + expect(runEfficiency).not.toBeInTheDocument() + expect(testPerf).not.toBeInTheDocument() + }) + }) + + describe('when on default branch', () => { + it('renders subheaders', async () => { + setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + const runEfficiency = await screen.findByText('Improve CI Run Efficiency') + const testPerf = await screen.findByText('Improve Test Performance') + expect(runEfficiency).toBeInTheDocument() + expect(testPerf).toBeInTheDocument() + }) + + it('renders total test runtime card', async () => { + setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + const title = await screen.findByText('Total test run time') + const context = await screen.findByText('24m 50s') + const description = await screen.findByText( + 'The cumulative CI time spent running tests over the last 30 days.' + ) + + expect(title).toBeInTheDocument() + expect(context).toBeInTheDocument() + expect(description).toBeInTheDocument() + }) + + describe('slowest tests card', () => { + it('renders slowest tests card', async () => { + setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + const title = await screen.findByText('Slowest tests') + const context = await screen.findByText(12) + const description = await screen.findByText( + 'The slowest 12 tests take 1m 51s to run.' + ) + + expect(title).toBeInTheDocument() + expect(context).toBeInTheDocument() + expect(description).toBeInTheDocument() + }) + + it('can update the location params on button click', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + const select = await screen.findByText('12') + expect(select).toBeInTheDocument() + await user.click(select) + + expect(testLocation?.state).toStrictEqual({ + parameter: 'SLOWEST_TESTS', + flags: [], + historicalTrend: '', + term: '', + testSuites: [], + }) + }) + + it('removes the location param on second button click', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + const select = await screen.findByText('12') + expect(select).toBeInTheDocument() + + await user.click(select) + await user.click(select) + + expect(testLocation?.state).toStrictEqual({ + parameter: '', + flags: [], + historicalTrend: '', + term: '', + testSuites: [], + }) + }) + }) + + describe('flaky tests card', () => { + it('renders total flaky tests card', async () => { + setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + const title = await screen.findByText('Flaky tests') + const context = await screen.findByText(88) + const description = await screen.findByText( + 'The number of flaky tests in your test suite.' + ) + + expect(title).toBeInTheDocument() + expect(context).toBeInTheDocument() + expect(description).toBeInTheDocument() + }) + + it('can update the location params on button click', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + const select = await screen.findByText(88) + expect(select).toBeInTheDocument() + await user.click(select) + + expect(testLocation?.state).toStrictEqual({ + parameter: 'FLAKY_TESTS', + flags: [], + historicalTrend: '', + term: '', + testSuites: [], + }) + }) + + it('removes the location param on second button click', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + const select = await screen.findByText(88) + expect(select).toBeInTheDocument() + + await user.click(select) + await user.click(select) + + expect(testLocation?.state).toStrictEqual({ + parameter: '', + flags: [], + historicalTrend: '', + term: '', + testSuites: [], + }) + }) + }) + + it('renders average flake rate card', async () => { + setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + const title = await screen.findByText('Avg. flake rate') + const context = await screen.findByText('8%') + const description = await screen.findByText( + 'The average flake rate across all branches.' + ) + + expect(title).toBeInTheDocument() + expect(context).toBeInTheDocument() + expect(description).toBeInTheDocument() + }) + + describe('total failures card', () => { + it('renders total failures card', async () => { + setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + const title = await screen.findByText('Failures') + const context = await screen.findByText(1) + const description = await screen.findByText( + 'The number of test failures across all branches.' + ) + + expect(title).toBeInTheDocument() + expect(context).toBeInTheDocument() + expect(description).toBeInTheDocument() + }) + + it('can update the location params on button click', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + const select = await screen.findByText(1) + expect(select).toBeInTheDocument() + await user.click(select) + + expect(testLocation?.state).toStrictEqual({ + parameter: 'FAILED_TESTS', + flags: [], + historicalTrend: '', + term: '', + testSuites: [], + }) + }) + + it('removes the location param on second button click', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + const select = await screen.findByText(1) + expect(select).toBeInTheDocument() + + await user.click(select) + await user.click(select) + + expect(testLocation?.state).toStrictEqual({ + parameter: '', + flags: [], + historicalTrend: '', + term: '', + testSuites: [], + }) + }) + }) + + describe('total skips card', () => { + it('renders total skips card', async () => { + setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + const title = await screen.findByText('Skipped tests') + const context = await screen.findByText(20) + const description = await screen.findByText( + 'The number of skipped tests in your test suite.' + ) + + expect(title).toBeInTheDocument() + expect(context).toBeInTheDocument() + expect(description).toBeInTheDocument() + }) + + it('can update the location params on button click', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + const select = await screen.findByText(20) + expect(select).toBeInTheDocument() + await user.click(select) + + expect(testLocation?.state).toStrictEqual({ + parameter: 'SKIPPED_TESTS', + flags: [], + historicalTrend: '', + term: '', + testSuites: [], + }) + }) + + it('removes the location param on second button click', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + const select = await screen.findByText(20) + expect(select).toBeInTheDocument() + + await user.click(select) + await user.click(select) + + expect(testLocation?.state).toStrictEqual({ + parameter: '', + flags: [], + historicalTrend: '', + term: '', + testSuites: [], + }) + }) + }) + }) + + describe('when on team plan', () => { + describe('when repo is private', () => { + it('does not render total flaky tests card', async () => { + setup('users-teamm', true) + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeAggregates = screen.queryByText('Flaky tests') + expect(flakeAggregates).not.toBeInTheDocument() + }) + + it('does not render avg flaky tests card', async () => { + setup('users-teamm', true) + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeAggregates = screen.queryByText('Avg. flake rate') + expect(flakeAggregates).not.toBeInTheDocument() + }) + }) + + describe('when repo is public', () => { + it('renders total flaky tests card', async () => { + setup('users-teamm', false) + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeAggregates = screen.queryByText('Flaky tests') + expect(flakeAggregates).toBeInTheDocument() + }) + + it('renders avg flaky tests card', async () => { + setup('users-teamm', false) + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeAggregates = screen.queryByText('Avg. flake rate') + expect(flakeAggregates).toBeInTheDocument() + }) + }) + }) + + describe('when on free plan', () => { + describe('when repo is private', () => { + it('does not render total flaky tests card', async () => { + setup('users-basic', true) + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeAggregates = screen.queryByText('Flaky tests') + expect(flakeAggregates).not.toBeInTheDocument() + }) + + it('does not render avg flaky tests card', async () => { + setup('users-basic', true) + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeAggregates = screen.queryByText('Avg. flake rate') + expect(flakeAggregates).not.toBeInTheDocument() + }) + }) + + describe('when repo is public', () => { + it('renders total flaky tests card', async () => { + setup('users-basic', false) + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeAggregates = screen.queryByText('Flaky tests') + expect(flakeAggregates).toBeInTheDocument() + }) + + it('renders avg flaky tests card', async () => { + setup('users-basic', false) + render(, { + wrapper: wrapper('/gh/owner/repo/tests/main'), + }) + + await waitFor(() => expect(queryClient.isFetching()).toBeFalsy()) + + const flakeAggregates = screen.queryByText('Avg. flake rate') + expect(flakeAggregates).toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/MetricsSection/MetricsSection.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/MetricsSection/MetricsSection.tsx new file mode 100644 index 0000000000..36fcdd895c --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/MetricsSection/MetricsSection.tsx @@ -0,0 +1,464 @@ +import qs from 'qs' +import { useLocation, useParams } from 'react-router-dom' + +import { MeasurementInterval } from 'pages/RepoPage/shared/constants' +import { useLocationParams } from 'services/navigation' +import { isFreePlan, isTeamPlan } from 'shared/utils/billing' +import { cn } from 'shared/utils/cn' +import { formatTimeFromSeconds } from 'shared/utils/dates' +import Badge from 'ui/Badge' +import Icon from 'ui/Icon' +import { MetricCard } from 'ui/MetricCard' +import { Tooltip } from 'ui/Tooltip' + +import { useFlakeAggregates } from '../hooks/useFlakeAggregates' +import { TestResultsFilterParameterType } from '../hooks/useInfiniteTestResults/useInfiniteTestResults' +import { useTestResultsAggregates } from '../hooks/useTestResultsAggregates' +import { defaultQueryParams } from '../SelectorSection' + +const PercentBadge = ({ value }: { value: number }) => { + let variant: 'success' | 'danger' = 'success' + let prefix = '' + + if (value > 0) { + variant = 'danger' + prefix = '+' + } + + return ( + + {prefix} + {value.toFixed(2)}% + + ) +} + +export const TooltipWithIcon = ({ + children, +}: { + children: React.ReactNode +}) => { + return ( + + + +
+ +
+
+ + + {children} + + + +
+
+ ) +} + +const TotalTestsRunTimeCard = ({ + totalDuration, + totalDurationPercentChange, + intervalCopy, + interval, +}: { + totalDuration?: number + totalDurationPercentChange?: number | null + intervalCopy: string + interval?: MeasurementInterval +}) => { + totalDurationPercentChange = 8.6 + + if (interval === 'INTERVAL_7_DAY') { + totalDurationPercentChange = 23 + } + + if (interval === 'INTERVAL_1_DAY') { + totalDurationPercentChange = -7.3 + } + + return ( + + + + Total test run time + + The total time it takes to run all your tests. + + + + + + {formatTimeFromSeconds(totalDuration)} + {totalDurationPercentChange ? ( + + ) : null} + + + The cumulative CI time spent running tests over the last {intervalCopy}. + + + ) +} + +const SlowestTestsCard = ({ + slowestTests, + slowestTestsPercentChange, + slowestTestsDuration, + isSelected, + updateParams, +}: { + slowestTests?: number + slowestTestsPercentChange?: number | null + slowestTestsDuration?: number | null + isSelected: boolean + updateParams: (newParams: { + parameter: TestResultsFilterParameterType | '' + }) => void +}) => { + return ( + + + + Slowest tests + + Lists the tests that take more than the 95th percentile run time to + complete. Showing a max of 100 tests. + + + + + + + {slowestTestsPercentChange ? ( + + ) : null} + + + The slowest {slowestTests} tests take{' '} + {formatTimeFromSeconds(slowestTestsDuration)} to run. + + + ) +} + +const TotalFlakyTestsCard = ({ + flakeCount, + flakeCountPercentChange, + isSelected, + updateParams, +}: { + flakeCount?: number + flakeCountPercentChange?: number | null + isSelected: boolean + updateParams: (newParams: { + parameter: TestResultsFilterParameterType | '' + }) => void +}) => { + return ( + + + + Flaky tests + + The number of tests that transition from fail to pass or pass to + fail. + + + + + + {flakeCountPercentChange ? ( + + ) : null} + + + The number of flaky tests in your test suite. + + + ) +} + +const AverageFlakeRateCard = ({ + flakeRate, + flakeRatePercentChange, +}: { + flakeRate?: number + flakeRatePercentChange?: number | null +}) => { + return ( + + + + Avg. flake rate + + The percentage of tests that flake, based on how many times a test + transitions from fail to pass or pass to fail on a given branch and + commit. + + + + + {flakeRate}% + {flakeRatePercentChange ? ( + + ) : null} + + + The average flake rate across all branches. + + + ) +} + +const TotalFailuresCard = ({ + totalFails, + totalFailsPercentChange, + isSelected, + updateParams, +}: { + totalFails?: number + totalFailsPercentChange?: number | null + isSelected: boolean + updateParams: (newParams: { + parameter: TestResultsFilterParameterType | '' + }) => void +}) => { + return ( + + + + Failures + + The number of failures indicate the number of errors that caused the + tests to fail. + + + + + + {totalFailsPercentChange ? ( + + ) : null} + + + The number of test failures across all branches. + + + ) +} + +const TotalSkippedTestsCard = ({ + totalSkips, + totalSkipsPercentChange, + isSelected, + updateParams, +}: { + totalSkips?: number + totalSkipsPercentChange?: number | null + isSelected: boolean + updateParams: (newParams: { + parameter: TestResultsFilterParameterType | '' + }) => void +}) => { + return ( + + + + Skipped tests + + The number of tests that were skipped. + + + + + + + {totalSkipsPercentChange ? ( + + ) : null} + + + The number of skipped tests in your test suite. + + + ) +} + +interface URLParams { + provider: string + owner: string + repo: string + branch?: string +} + +const getDecodedBranch = (branch?: string) => + !!branch ? decodeURIComponent(branch) : undefined + +export const historicalTrendToCopy = (interval?: MeasurementInterval) => { + switch (interval) { + case 'INTERVAL_1_DAY': + return '1 day' + case 'INTERVAL_7_DAY': + return '7 days' + case 'INTERVAL_30_DAY': + default: + return '30 days' + } +} + +function MetricsSection() { + const { branch } = useParams() + + const { updateParams } = useLocationParams(defaultQueryParams) + + const location = useLocation() + const queryParams = qs.parse(location.search, { + ignoreQueryPrefix: true, + depth: 1, + }) + + const { data: testResults } = useTestResultsAggregates({ + interval: queryParams?.historicalTrend as MeasurementInterval, + }) + const disabledFlakeAggregates = + (isTeamPlan(testResults?.plan) || isFreePlan(testResults?.plan)) && + testResults?.private + const { data: flakeAggregates } = useFlakeAggregates({ + interval: queryParams?.historicalTrend as MeasurementInterval, + opts: { + enabled: !disabledFlakeAggregates, + }, + }) + + const decodedBranch = getDecodedBranch(branch) + const selectedBranch = decodedBranch ?? testResults?.defaultBranch ?? '' + + if (selectedBranch !== testResults?.defaultBranch) { + return null + } + + const aggregates = testResults?.testResultsAggregates + + return ( + <> +
+
+
+

+ Improve CI Run Efficiency +

+
+ + +
+
+
+

+ Improve Test Performance +

+
+ {!!flakeAggregates ? ( + <> + + + + ) : null} + + +
+
+
+ + ) +} + +export default MetricsSection diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/MetricsSection/index.ts b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/MetricsSection/index.ts new file mode 100644 index 0000000000..3092440326 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/MetricsSection/index.ts @@ -0,0 +1 @@ +export { default as MetricsSection } from './MetricsSection' diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsTable/BranchSelector/BranchSelector.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/SelectorSection/BranchSelector/BranchSelector.test.tsx similarity index 100% rename from src/pages/RepoPage/FailedTestsTab/FailedTestsTable/BranchSelector/BranchSelector.test.tsx rename to src/pages/RepoPage/FailedTestsTab/FailedTestsPage/SelectorSection/BranchSelector/BranchSelector.test.tsx diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsTable/BranchSelector/BranchSelector.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/SelectorSection/BranchSelector/BranchSelector.tsx similarity index 96% rename from src/pages/RepoPage/FailedTestsTab/FailedTestsTable/BranchSelector/BranchSelector.tsx rename to src/pages/RepoPage/FailedTestsTab/FailedTestsPage/SelectorSection/BranchSelector/BranchSelector.tsx index dec4d97aff..763c57a287 100644 --- a/src/pages/RepoPage/FailedTestsTab/FailedTestsTable/BranchSelector/BranchSelector.tsx +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/SelectorSection/BranchSelector/BranchSelector.tsx @@ -77,14 +77,14 @@ const BranchSelector = () => { } return ( -
+

Branch Context

- + + updateParams({ historicalTrend: historicalTrend.value }) + } + renderItem={({ label }: { label: string }) => label} + renderSelected={({ label }: { label: string }) => label} + /> +
+ + 60 day retention + +
+
+

+ Test suites +

+
+ { + setSelectedTestSuites(testSuites) + updateParams({ testSuites }) + }} + onSearch={(term: string) => setTestSuiteSearch(term)} + items={testSuiteResults?.testSuites ?? []} + renderSelected={(selectedItems: string[]) => ( + + + {selectedItems.length === 0 ? ( + 'All test suites' + ) : ( + {selectedItems.length} selected test suites + )} + + )} + resourceName="Test Suites" + /> +
+
+
+

+ Flags +

+
+ { + setSelectedFlags(flags) + updateParams({ flags }) + }} + onSearch={(term: string) => setFlagSearch(term)} + items={flagResults?.flags ?? []} + renderSelected={(selectedItems: string[]) => ( + + + {selectedItems.length === 0 ? ( + 'All flags' + ) : ( + {selectedItems.length} selected flags + )} + + )} + /> +
+
+ + ) : null} + + ) +} + +export default SelectorSection diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/SelectorSection/index.ts b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/SelectorSection/index.ts new file mode 100644 index 0000000000..764efa47c1 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/SelectorSection/index.ts @@ -0,0 +1,2 @@ +export { default as SelectorSection } from './SelectorSection' +export { defaultQueryParams } from './SelectorSection' diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/TableHeader/TableHeader.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/TableHeader/TableHeader.test.tsx new file mode 100644 index 0000000000..be947442df --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/TableHeader/TableHeader.test.tsx @@ -0,0 +1,153 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PropsWithChildren, Suspense } from 'react' +import { MemoryRouter, Route, useLocation } from 'react-router-dom' + +import TableHeader from './TableHeader' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { suspense: true, retry: false } }, +}) + +let testLocation: ReturnType +const wrapper: (initialEntries?: string) => React.FC = + (initialEntries = '/gh/codecov/cool-repo/tests') => + ({ children }) => ( + + + + {children} + + { + testLocation = location + return null + }} + /> + + + ) + +describe('TableHeader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the TableHeader component', () => { + render(, { + wrapper: wrapper(), + }) + const testsText = screen.getByText('Tests (50.0K)') + const searchInput = screen.getByPlaceholderText('Search by name') + const resetButton = screen.getByText('Reset to default') + + expect(testsText).toBeInTheDocument() + expect(searchInput).toBeInTheDocument() + expect(resetButton).toBeInTheDocument() + }) + + it('updates search term on input change', async () => { + render(, { + wrapper: wrapper(), + }) + const searchInput = screen.getByPlaceholderText('Search by name') + await userEvent.type(searchInput, 'test') + expect(searchInput).toHaveValue('test') + await waitFor(() => { + expect(testLocation.search).toContain('term=test') + }) + }) + + it('resets to default parameters on button click', async () => { + render(, { + wrapper: wrapper('/gh/codecov/cool-repo/tests?term=test'), + }) + const resetButton = screen.getByText('Reset to default') + await userEvent.click(resetButton) + await waitFor(() => { + expect(testLocation.search).toBe('') + }) + }) + + it('disables reset button when parameters are default', () => { + render(, { + wrapper: wrapper(), + }) + const resetButton = screen.getByText('Reset to default') + expect(resetButton).toBeDisabled() + }) + + it('enables reset button when parameters are not default', () => { + render(, { + wrapper: wrapper('/gh/codecov/cool-repo/tests?term=test'), + }) + const resetButton = screen.getByText('Reset to default') + expect(resetButton).not.toBeDisabled() + }) + + it('hides reset button when not on default branch', () => { + render(, { + wrapper: wrapper(), + }) + const resetButton = screen.queryByText('Reset to default') + expect(resetButton).not.toBeInTheDocument() + }) + + describe('header title', () => { + it('renders the default header title', () => { + render(, { + wrapper: wrapper(), + }) + const headerTitle = screen.getByText('Tests (50.0K)') + expect(headerTitle).toBeInTheDocument() + }) + + it('renders the flaky tests header title', () => { + render(, { + wrapper: wrapper('/gh/codecov/cool-repo/tests?parameter=FLAKY_TESTS'), + }) + const headerTitle = screen.getByText('Flaky tests (50.0K)') + expect(headerTitle).toBeInTheDocument() + }) + + it('renders the failed tests header title', () => { + render(, { + wrapper: wrapper('/gh/codecov/cool-repo/tests?parameter=FAILED_TESTS'), + }) + const headerTitle = screen.getByText('Failed tests (50.0K)') + expect(headerTitle).toBeInTheDocument() + }) + + it('renders the tests header title', () => { + render(, { + wrapper: wrapper('/gh/codecov/cool-repo/tests?parameter=TESTS'), + }) + const headerTitle = screen.getByText('Tests (50.0K)') + expect(headerTitle).toBeInTheDocument() + }) + + it('renders the skipped tests header title', () => { + render(, { + wrapper: wrapper('/gh/codecov/cool-repo/tests?parameter=SKIPPED_TESTS'), + }) + const headerTitle = screen.getByText('Skipped tests (50.0K)') + expect(headerTitle).toBeInTheDocument() + }) + + it('renders the slowest tests header title', () => { + render(, { + wrapper: wrapper('/gh/codecov/cool-repo/tests?parameter=SLOWEST_TESTS'), + }) + const headerTitle = screen.getByText('Slowest tests (50.0K)') + expect(headerTitle).toBeInTheDocument() + }) + }) +}) diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/TableHeader/TableHeader.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/TableHeader/TableHeader.tsx new file mode 100644 index 0000000000..d6c8193df3 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/TableHeader/TableHeader.tsx @@ -0,0 +1,78 @@ +import capitalize from 'lodash/capitalize' +import { useState } from 'react' + +import { useLocationParams } from 'services/navigation' +import Button from 'ui/Button' +import SearchField from 'ui/SearchField' + +import { TestResultsFilterParameter } from '../hooks/useInfiniteTestResults/useInfiniteTestResults' +import { defaultQueryParams } from '../SelectorSection' + +interface TableHeaderProps { + totalCount: number + isDefaultBranch: boolean +} + +const getHeaderTitle = (parameter: keyof typeof TestResultsFilterParameter) => { + return parameter && TestResultsFilterParameter[parameter] + ? capitalize(parameter.replace('_', ' ')) + : 'Tests' +} + +const TableHeader: React.FC = ({ + totalCount, + isDefaultBranch, +}) => { + const { params, updateParams } = useLocationParams(defaultQueryParams) + // @ts-expect-error, useLocationParams needs to be updated to have full types + const [searchTerm, setSearchTerm] = useState(params.term) + + const handleSearchChange = (value: string) => { + setSearchTerm(value) + updateParams({ ...params, term: value }) + } + + const isParamsDefault = + JSON.stringify(params) === JSON.stringify(defaultQueryParams) + + return ( + <> +
+
+

+ {/* @ts-expect-error, params is not typed */} + {getHeaderTitle(params?.parameter)} ( + {totalCount > 999 ? `${(totalCount / 1000).toFixed(1)}K` : totalCount} + ) +

+
+ + {isDefaultBranch ? ( + + ) : null} +
+
+ + ) +} + +export default TableHeader diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/TableHeader/index.ts b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/TableHeader/index.ts new file mode 100644 index 0000000000..b344f789c4 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/TableHeader/index.ts @@ -0,0 +1 @@ +export { default as TableHeader } from './TableHeader' diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useFlakeAggregates/index.ts b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useFlakeAggregates/index.ts new file mode 100644 index 0000000000..2f3f6a01f2 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useFlakeAggregates/index.ts @@ -0,0 +1 @@ +export { useFlakeAggregates } from './useFlakeAggregates' diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useFlakeAggregates/useFlakeAggregates.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useFlakeAggregates/useFlakeAggregates.test.tsx new file mode 100644 index 0000000000..3e5f3e6151 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useFlakeAggregates/useFlakeAggregates.test.tsx @@ -0,0 +1,179 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route } from 'react-router-dom' +import { MockInstance } from 'vitest' + +import { MEASUREMENT_INTERVAL } from 'pages/RepoPage/shared/constants' + +import { useFlakeAggregates } from './useFlakeAggregates' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +) + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +const mockNotFoundError = { + owner: { + repository: { + __typename: 'NotFoundError', + message: 'repo not found', + }, + }, +} + +const mockIncorrectResponse = { + owner: { + repository: { + invalid: 'invalid', + }, + }, +} + +const mockResponse = { + owner: { + repository: { + __typename: 'Repository', + testAnalytics: { + flakeAggregates: { + flakeCount: 10, + flakeCountPercentChange: 5.0, + flakeRate: 0.1, + flakeRatePercentChange: 2.0, + }, + }, + }, + }, +} + +describe('useFlakeAggregates', () => { + function setup({ + isNotFoundError = false, + isUnsuccessfulParseError = false, + }) { + server.use( + graphql.query('GetFlakeAggregates', (info) => { + if (isNotFoundError) { + return HttpResponse.json({ data: mockNotFoundError }) + } else if (isUnsuccessfulParseError) { + return HttpResponse.json({ data: mockIncorrectResponse }) + } + return HttpResponse.json({ data: mockResponse }) + }) + ) + } + + describe('when called with successful res', () => { + describe('when data is loaded', () => { + it('returns the data', async () => { + setup({}) + const { result } = renderHook( + () => + useFlakeAggregates({ + interval: MEASUREMENT_INTERVAL.INTERVAL_1_DAY, + }), + { + wrapper, + } + ) + + await waitFor(() => result.current.isLoading) + await waitFor(() => !result.current.isLoading) + + await waitFor(() => + expect(result.current.data).toEqual({ + flakeCount: 10, + flakeCountPercentChange: 5.0, + flakeRate: 0.1, + flakeRatePercentChange: 2.0, + }) + ) + }) + }) + }) + + describe('when failed to parse data', () => { + let consoleSpy: MockInstance + beforeAll(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterAll(() => { + consoleSpy.mockRestore() + }) + + it('returns a failed to parse error', async () => { + setup({ isUnsuccessfulParseError: true }) + const { result } = renderHook( + () => + useFlakeAggregates({ interval: MEASUREMENT_INTERVAL.INTERVAL_1_DAY }), + { + wrapper, + } + ) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + dev: 'useFlakeAggregates - 404 Failed to parse data', + }) + ) + ) + }) + }) + + describe('when data not found', () => { + let consoleSpy: MockInstance + beforeAll(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterAll(() => { + consoleSpy.mockRestore() + }) + + it('returns a not found error', async () => { + setup({ isNotFoundError: true }) + const { result } = renderHook( + () => + useFlakeAggregates({ interval: MEASUREMENT_INTERVAL.INTERVAL_1_DAY }), + { + wrapper, + } + ) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + data: {}, + }) + ) + ) + }) + }) +}) diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useFlakeAggregates/useFlakeAggregates.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useFlakeAggregates/useFlakeAggregates.tsx new file mode 100644 index 0000000000..6f408c889f --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useFlakeAggregates/useFlakeAggregates.tsx @@ -0,0 +1,120 @@ +import { useQuery } from '@tanstack/react-query' +import { useParams } from 'react-router-dom' +import { z } from 'zod' + +import { MeasurementInterval } from 'pages/RepoPage/shared/constants' +import { RepoNotFoundErrorSchema } from 'services/repo' +import Api from 'shared/api' +import { NetworkErrorObject } from 'shared/api/helpers' + +const FlakeAggregatesSchema = z.object({ + owner: z + .object({ + repository: z.discriminatedUnion('__typename', [ + z.object({ + __typename: z.literal('Repository'), + testAnalytics: z + .object({ + flakeAggregates: z.object({ + flakeCount: z.number(), + flakeCountPercentChange: z.number().nullable(), + flakeRate: z.number(), + flakeRatePercentChange: z.number().nullable(), + }), + }) + .nullable(), + }), + RepoNotFoundErrorSchema, + ]), + }) + .nullable(), +}) + +const query = ` + query GetFlakeAggregates( + $owner: String! + $repo: String! + $interval: MeasurementInterval + ) { + owner(username: $owner) { + repository: repository(name: $repo) { + __typename + ... on Repository { + testAnalytics { + flakeAggregates(interval: $interval) { + flakeCount + flakeCountPercentChange + flakeRate + flakeRatePercentChange + } + } + } + ... on NotFoundError { + message + } + } + } + } + ` + +interface URLParams { + provider: string + owner: string + repo: string +} + +interface UseFlakeAggregatesOptions { + enabled?: boolean + suspense?: boolean +} + +interface UseFlakeAggregatesParams { + interval?: MeasurementInterval + opts?: UseFlakeAggregatesOptions +} + +export const useFlakeAggregates = ({ + interval, + opts, +}: UseFlakeAggregatesParams = {}) => { + const { provider, owner, repo } = useParams() + + return useQuery({ + queryKey: ['GetFlakeAggregates', provider, owner, repo, interval], + queryFn: ({ signal }) => + Api.graphql({ + provider, + query, + signal, + variables: { + provider, + owner, + repo, + interval, + }, + }).then((res) => { + const parsedData = FlakeAggregatesSchema.safeParse(res?.data) + + if (!parsedData.success) { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useFlakeAggregates - 404 Failed to parse data', + } satisfies NetworkErrorObject) + } + + const data = parsedData.data + + if (data?.owner?.repository?.__typename === 'NotFoundError') { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useFlakeAggregates - 404 Not found error', + } satisfies NetworkErrorObject) + } + + return data.owner?.repository.testAnalytics?.flakeAggregates + }), + ...opts, + }) +} diff --git a/src/pages/RepoPage/FailedTestsTab/hooks/index.ts b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useInfiniteTestResults/index.ts similarity index 100% rename from src/pages/RepoPage/FailedTestsTab/hooks/index.ts rename to src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useInfiniteTestResults/index.ts diff --git a/src/pages/RepoPage/FailedTestsTab/hooks/useInfiniteTestResults.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useInfiniteTestResults/useInfiniteTestResults.test.tsx similarity index 80% rename from src/pages/RepoPage/FailedTestsTab/hooks/useInfiniteTestResults.test.tsx rename to src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useInfiniteTestResults/useInfiniteTestResults.test.tsx index 8cdef9b52b..6d8019c623 100644 --- a/src/pages/RepoPage/FailedTestsTab/hooks/useInfiniteTestResults.test.tsx +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useInfiniteTestResults/useInfiniteTestResults.test.tsx @@ -7,8 +7,13 @@ import { useInfiniteTestResults } from './useInfiniteTestResults' const mockTestResults = { owner: { + plan: { + value: 'users-enterprisem', + }, repository: { __typename: 'Repository', + private: false, + defaultBranch: 'main', testAnalytics: { testResults: { edges: [ @@ -18,7 +23,12 @@ const mockTestResults = { name: 'test-1', commitsFailed: 1, failureRate: 0.1, + flakeRate: 0.0, avgDuration: 10, + totalFailCount: 5, + totalFlakyFailCount: 14, + totalPassCount: 6, + totalSkipCount: 7, }, }, { @@ -27,7 +37,26 @@ const mockTestResults = { name: 'test-2', commitsFailed: 2, failureRate: 0.2, + flakeRate: 0.0, avgDuration: 20, + totalFailCount: 8, + totalFlakyFailCount: 15, + totalPassCount: 9, + totalSkipCount: 10, + }, + }, + { + node: { + updatedAt: '2023-01-03T00:00:00Z', + name: 'test-3', + commitsFailed: 3, + failureRate: 0.2, + flakeRate: 0.1, + avgDuration: 30, + totalFailCount: 11, + totalFlakyFailCount: 16, + totalPassCount: 12, + totalSkipCount: 13, }, }, ], @@ -35,6 +64,7 @@ const mockTestResults = { endCursor: 'cursor-2', hasNextPage: true, }, + totalCount: 55, }, }, }, @@ -47,11 +77,17 @@ const mockNotFoundError = { __typename: 'NotFoundError', message: 'Repository not found', }, + plan: { + value: 'users-enterprisem', + }, }, } const mockOwnerNotActivatedError = { owner: { + plan: { + value: 'users-enterprisem', + }, repository: { __typename: 'OwnerNotActivatedError', message: 'Owner not activated', @@ -139,14 +175,36 @@ describe('useInfiniteTestResults', () => { name: 'test-1', commitsFailed: 1, failureRate: 0.1, + flakeRate: 0.0, avgDuration: 10, + totalFailCount: 5, + totalFlakyFailCount: 14, + totalPassCount: 6, + totalSkipCount: 7, }, { updatedAt: '2023-01-02T00:00:00Z', name: 'test-2', commitsFailed: 2, failureRate: 0.2, + flakeRate: 0.0, avgDuration: 20, + totalFailCount: 8, + totalFlakyFailCount: 15, + totalPassCount: 9, + totalSkipCount: 10, + }, + { + updatedAt: '2023-01-03T00:00:00Z', + name: 'test-3', + commitsFailed: 3, + failureRate: 0.2, + flakeRate: 0.1, + avgDuration: 30, + totalFailCount: 11, + totalFlakyFailCount: 16, + totalPassCount: 12, + totalSkipCount: 13, }, ]) ) diff --git a/src/pages/RepoPage/FailedTestsTab/hooks/useInfiniteTestResults.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useInfiniteTestResults/useInfiniteTestResults.tsx similarity index 76% rename from src/pages/RepoPage/FailedTestsTab/hooks/useInfiniteTestResults.tsx rename to src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useInfiniteTestResults/useInfiniteTestResults.tsx index 755eed7a7a..b89bb5eb30 100644 --- a/src/pages/RepoPage/FailedTestsTab/hooks/useInfiniteTestResults.tsx +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useInfiniteTestResults/useInfiniteTestResults.tsx @@ -5,6 +5,7 @@ import { import { useMemo } from 'react' import { z } from 'zod' +import { MeasurementInterval } from 'pages/RepoPage/shared/constants' import { RepoNotFoundErrorSchema, RepoOwnerNotActivatedErrorSchema, @@ -19,7 +20,12 @@ const TestResultSchema = z.object({ name: z.string(), commitsFailed: z.number().nullable(), failureRate: z.number().nullable(), + flakeRate: z.number().nullable(), avgDuration: z.number().nullable(), + totalFailCount: z.number(), + totalFlakyFailCount: z.number(), + totalSkipCount: z.number(), + totalPassCount: z.number(), }) export const OrderingDirection = { @@ -29,11 +35,22 @@ export const OrderingDirection = { export const OrderingParameter = { AVG_DURATION: 'AVG_DURATION', + FLAKE_RATE: 'FLAKE_RATE', FAILURE_RATE: 'FAILURE_RATE', COMMITS_WHERE_FAIL: 'COMMITS_WHERE_FAIL', UPDATED_AT: 'UPDATED_AT', } as const +export const TestResultsFilterParameter = { + FLAKY_TESTS: 'FLAKY_TESTS', + FAILED_TESTS: 'FAILED_TESTS', + SLOWEST_TESTS: 'SLOWEST_TESTS', + SKIPPED_TESTS: 'SKIPPED_TESTS', +} as const + +export type TestResultsFilterParameterType = + keyof typeof TestResultsFilterParameter + export const TestResultsOrdering = z.object({ direction: z.nativeEnum(OrderingDirection), parameter: z.nativeEnum(OrderingParameter), @@ -44,9 +61,16 @@ type TestResult = z.infer const GetTestResultsSchema = z.object({ owner: z .object({ + plan: z + .object({ + value: z.string(), + }) + .nullable(), repository: z.discriminatedUnion('__typename', [ z.object({ __typename: z.literal('Repository'), + private: z.boolean().nullable(), + defaultBranch: z.string().nullable(), testAnalytics: z .object({ testResults: z.object({ @@ -59,6 +83,7 @@ const GetTestResultsSchema = z.object({ endCursor: z.string().nullable(), hasNextPage: z.boolean(), }), + totalCount: z.number(), }), }) .nullable(), @@ -82,9 +107,14 @@ query GetTestResults( $before: String ) { owner(username: $owner) { + plan { + value + } repository: repository(name: $repo) { __typename ... on Repository { + private + defaultBranch testAnalytics { testResults( filters: $filters @@ -100,13 +130,19 @@ query GetTestResults( avgDuration name failureRate + flakeRate commitsFailed + totalFailCount + totalFlakyFailCount + totalSkipCount + totalPassCount } } pageInfo { endCursor hasNextPage } + totalCount } } } @@ -127,6 +163,11 @@ interface UseTestResultsArgs { repo: string filters?: { branch?: string + flags?: string[] + interval?: MeasurementInterval + parameter?: TestResultsFilterParameterType + term?: string + test_suites?: string[] } ordering?: z.infer first?: number @@ -136,6 +177,10 @@ interface UseTestResultsArgs { opts?: UseInfiniteQueryOptions<{ testResults: TestResult[] pageInfo: { endCursor: string | null; hasNextPage: boolean } + private: boolean | null + plan: string | null + defaultBranch: string | null + totalCount: number | null }> } @@ -227,6 +272,12 @@ export const useInfiniteTestResults = ({ hasNextPage: false, endCursor: null, }, + totalCount: + data?.owner?.repository?.testAnalytics?.testResults?.totalCount ?? + 0, + private: data?.owner?.repository?.private ?? null, + plan: data?.owner?.plan?.value ?? null, + defaultBranch: data?.owner?.repository?.defaultBranch ?? null, } }), getNextPageParam: (lastPage) => { @@ -245,6 +296,10 @@ export const useInfiniteTestResults = ({ return { data: { testResults: memoedData, + totalCount: data?.pages?.[0]?.totalCount ?? 0, + private: data?.pages?.[0]?.private ?? null, + plan: data?.pages?.[0]?.plan ?? null, + defaultBranch: data?.pages?.[0]?.defaultBranch ?? null, }, ...rest, } diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsAggregates/index.ts b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsAggregates/index.ts new file mode 100644 index 0000000000..8d4f85b025 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsAggregates/index.ts @@ -0,0 +1 @@ +export { useTestResultsAggregates } from './useTestResultsAggregates' diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsAggregates/useTestResultsAggregates.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsAggregates/useTestResultsAggregates.tsx new file mode 100644 index 0000000000..5508667f97 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsAggregates/useTestResultsAggregates.tsx @@ -0,0 +1,140 @@ +import { useQuery } from '@tanstack/react-query' +import { useParams } from 'react-router-dom' +import { z } from 'zod' + +import { MeasurementInterval } from 'pages/RepoPage/shared/constants' +import { RepoNotFoundErrorSchema } from 'services/repo' +import Api from 'shared/api' +import { NetworkErrorObject } from 'shared/api/helpers' + +const TestResultsAggregatesSchema = z.object({ + owner: z + .object({ + plan: z + .object({ + value: z.string(), + }) + .nullable(), + repository: z.discriminatedUnion('__typename', [ + z.object({ + __typename: z.literal('Repository'), + private: z.boolean().nullable(), + defaultBranch: z.string().nullable(), + testAnalytics: z + .object({ + testResultsAggregates: z.object({ + totalDuration: z.number(), + totalDurationPercentChange: z.number().nullable(), + slowestTestsDuration: z.number(), + slowestTestsDurationPercentChange: z.number().nullable(), + totalSlowTests: z.number(), + totalSlowTestsPercentChange: z.number().nullable(), + totalFails: z.number(), + totalFailsPercentChange: z.number().nullable(), + totalSkips: z.number(), + totalSkipsPercentChange: z.number().nullable(), + }), + }) + .nullable(), + }), + RepoNotFoundErrorSchema, + ]), + }) + .nullable(), +}) + +const query = ` + query GetTestResultsAggregates( + $owner: String! + $repo: String! + $interval: MeasurementInterval + ) { + owner(username: $owner) { + plan { + value + } + repository: repository(name: $repo) { + __typename + ... on Repository { + private + defaultBranch + testAnalytics { + testResultsAggregates(interval: $interval) { + totalDuration + totalDurationPercentChange + slowestTestsDuration + slowestTestsDurationPercentChange + totalSlowTests + totalSlowTestsPercentChange + totalFails + totalFailsPercentChange + totalSkips + totalSkipsPercentChange + } + } + } + ... on NotFoundError { + message + } + } + } + } + ` + +interface URLParams { + provider: string + owner: string + repo: string +} + +export const useTestResultsAggregates = ({ + interval, +}: { + interval?: MeasurementInterval +}) => { + const { provider, owner, repo } = useParams() + + return useQuery({ + queryKey: ['GetTestResultsAggregates', provider, owner, repo, interval], + queryFn: ({ signal }) => + Api.graphql({ + provider, + query, + signal, + variables: { + provider, + owner, + repo, + interval, + }, + }).then((res) => { + const parsedData = TestResultsAggregatesSchema.safeParse(res?.data) + + if (!parsedData.success) { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useTestResultsAggregates - 404 Failed to parse data', + } satisfies NetworkErrorObject) + } + + const data = parsedData.data + + if (data?.owner?.repository?.__typename === 'NotFoundError') { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useTestResultsAggregates - 404 Not found error', + } satisfies NetworkErrorObject) + } + + return { + testResultsAggregates: + data?.owner?.repository?.testAnalytics?.testResultsAggregates, + plan: data?.owner?.plan?.value, + private: data?.owner?.repository?.private, + defaultBranch: data?.owner?.repository?.defaultBranch, + } + }), + }) +} diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsAggregates/useTestsResultsAggregates.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsAggregates/useTestsResultsAggregates.test.tsx new file mode 100644 index 0000000000..1505050332 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsAggregates/useTestsResultsAggregates.test.tsx @@ -0,0 +1,191 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route } from 'react-router-dom' +import { MockInstance } from 'vitest' + +import { useTestResultsAggregates } from './useTestResultsAggregates' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +) + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +const mockNotFoundError = { + owner: { + repository: { + __typename: 'NotFoundError', + message: 'repo not found', + }, + plan: { + value: 'users-basic', + }, + }, +} + +const mockIncorrectResponse = { + owner: { + repository: { + invalid: 'invalid', + }, + plan: { + value: 'users-basic', + }, + }, +} + +const mockResponse = { + owner: { + plan: { + value: 'users-basic', + }, + repository: { + __typename: 'Repository', + private: true, + defaultBranch: 'main', + testAnalytics: { + testResultsAggregates: { + totalDuration: 1.0, + totalDurationPercentChange: 25.0, + slowestTestsDuration: 111.11, + slowestTestsDurationPercentChange: 0.0, + totalSlowTests: 2, + totalSlowTestsPercentChange: 15.1, + totalFails: 1, + totalFailsPercentChange: 100.0, + totalSkips: 20, + totalSkipsPercentChange: 0.0, + }, + }, + }, + }, +} + +describe('useTestResultsAggregates', () => { + function setup({ + isNotFoundError = false, + isUnsuccessfulParseError = false, + }) { + server.use( + graphql.query('GetTestResultsAggregates', (info) => { + if (isNotFoundError) { + return HttpResponse.json({ data: mockNotFoundError }) + } else if (isUnsuccessfulParseError) { + return HttpResponse.json({ data: mockIncorrectResponse }) + } + return HttpResponse.json({ data: mockResponse }) + }) + ) + } + + describe('when called with successful res', () => { + describe('when data is loaded', () => { + it('returns the data', async () => { + setup({}) + const { result } = renderHook(() => useTestResultsAggregates({}), { + wrapper, + }) + + await waitFor(() => result.current.isLoading) + await waitFor(() => !result.current.isLoading) + + await waitFor(() => + expect(result.current.data).toEqual({ + testResultsAggregates: { + totalDuration: 1, + totalDurationPercentChange: 25, + slowestTestsDuration: 111.11, + slowestTestsDurationPercentChange: 0, + totalFails: 1, + totalFailsPercentChange: 100, + totalSlowTests: 2, + totalSlowTestsPercentChange: 15.1, + totalSkips: 20, + totalSkipsPercentChange: 0, + }, + plan: 'users-basic', + private: true, + defaultBranch: 'main', + }) + ) + }) + }) + }) + + describe('when failed to parse data', () => { + let consoleSpy: MockInstance + beforeAll(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterAll(() => { + consoleSpy.mockRestore() + }) + + it('returns a failed to parse error', async () => { + setup({ isUnsuccessfulParseError: true }) + const { result } = renderHook(() => useTestResultsAggregates({}), { + wrapper, + }) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + dev: 'useTestResultsAggregates - 404 Failed to parse data', + }) + ) + ) + }) + }) + + describe('when data not found', () => { + let consoleSpy: MockInstance + beforeAll(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterAll(() => { + consoleSpy.mockRestore() + }) + + it('returns a not found error', async () => { + setup({ isNotFoundError: true }) + const { result } = renderHook(() => useTestResultsAggregates({}), { + wrapper, + }) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + data: {}, + }) + ) + ) + }) + }) +}) diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsFlags/useTestResultsFlags.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsFlags/useTestResultsFlags.test.tsx new file mode 100644 index 0000000000..2f06b680e2 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsFlags/useTestResultsFlags.test.tsx @@ -0,0 +1,153 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route } from 'react-router-dom' +import { MockInstance } from 'vitest' + +import { useTestResultsFlags } from './useTestResultsFlags' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +) + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +const mockNotFoundError = { + owner: { + repository: { + __typename: 'NotFoundError', + message: 'repo not found', + }, + }, +} + +const mockIncorrectResponse = { + owner: { + repository: { + invalid: 'invalid', + }, + }, +} + +const mockResponse = { + owner: { + repository: { + __typename: 'Repository', + testAnalytics: { + flags: ['nah', 'ok', 'three'], + }, + }, + }, +} + +describe.skip('useTestResultsFlags', () => { + function setup({ + isNotFoundError = false, + isUnsuccessfulParseError = false, + }) { + server.use( + graphql.query('GetTestResultsFlags', (info) => { + if (isNotFoundError) { + return HttpResponse.json({ data: mockNotFoundError }) + } else if (isUnsuccessfulParseError) { + return HttpResponse.json({ data: mockIncorrectResponse }) + } + return HttpResponse.json({ data: mockResponse }) + }) + ) + } + + describe('when called with successful res', () => { + it('returns the data', async () => { + setup({}) + const { result } = renderHook(() => useTestResultsFlags({}), { + wrapper, + }) + + await waitFor(() => result.current.isLoading) + await waitFor(() => !result.current.isLoading) + + await waitFor(() => + expect(result.current.data).toEqual({ + flags: ['nah', 'ok', 'three'], + }) + ) + }) + }) + + describe('when failed to parse data', () => { + let consoleSpy: MockInstance + beforeAll(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterAll(() => { + consoleSpy.mockRestore() + }) + + it('returns a failed to parse error', async () => { + setup({ isUnsuccessfulParseError: true }) + const { result } = renderHook(() => useTestResultsFlags({}), { + wrapper, + }) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + dev: 'useTestResultsFlags - 404 Failed to parse data', + }) + ) + ) + }) + }) + + describe('when data not found', () => { + let consoleSpy: MockInstance + beforeAll(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterAll(() => { + consoleSpy.mockRestore() + }) + + it('returns a not found error', async () => { + setup({ isNotFoundError: true }) + const { result } = renderHook(() => useTestResultsFlags({}), { + wrapper, + }) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + data: {}, + }) + ) + ) + }) + }) +}) diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsFlags/useTestResultsFlags.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsFlags/useTestResultsFlags.tsx new file mode 100644 index 0000000000..cd352bb07a --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsFlags/useTestResultsFlags.tsx @@ -0,0 +1,98 @@ +import { useQuery } from '@tanstack/react-query' +import { useParams } from 'react-router-dom' +import { z } from 'zod' + +import { RepoNotFoundErrorSchema } from 'services/repo' +import Api from 'shared/api' +import { NetworkErrorObject } from 'shared/api/helpers' + +const TestResultsFlagsSchema = z.object({ + owner: z + .object({ + repository: z.discriminatedUnion('__typename', [ + z.object({ + __typename: z.literal('Repository'), + testAnalytics: z + .object({ + flags: z.array(z.string()), + }) + .nullable(), + }), + RepoNotFoundErrorSchema, + ]), + }) + .nullable(), +}) + +const query = ` + query GetTestResultsFlags( + $owner: String! + $repo: String! + $term: String + ) { + owner(username: $owner) { + repository: repository(name: $repo) { + __typename + ... on Repository { + testAnalytics { + flags(term: $term) + } + } + ... on NotFoundError { + message + } + } + } + } + ` + +interface URLParams { + provider: string + owner: string + repo: string +} + +export const useTestResultsFlags = ({ term }: { term?: string }) => { + const { provider, owner, repo } = useParams() + + return useQuery({ + queryKey: ['GetTestResultsFlags', provider, owner, repo, term], + queryFn: ({ signal }) => + Api.graphql({ + provider, + query, + signal, + variables: { + provider, + owner, + repo, + term, + }, + }).then((res) => { + const parsedData = TestResultsFlagsSchema.safeParse(res?.data) + + if (!parsedData.success) { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useTestResultsFlags - 404 Failed to parse data', + } satisfies NetworkErrorObject) + } + + const data = parsedData.data + + if (data?.owner?.repository?.__typename === 'NotFoundError') { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useTestResultsFlags - 404 Not found error', + } satisfies NetworkErrorObject) + } + + return { + // flags: data?.owner?.repository?.testAnalytics?.flags, + flags: ['test_analytics', 'is', 'pretty', 'cool'], + } + }), + }) +} diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsTestSuites/useTestResultsTestSuites.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsTestSuites/useTestResultsTestSuites.test.tsx new file mode 100644 index 0000000000..7f2f314b74 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsTestSuites/useTestResultsTestSuites.test.tsx @@ -0,0 +1,153 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route } from 'react-router-dom' +import { MockInstance } from 'vitest' + +import { useTestResultsTestSuites } from './useTestResultsTestSuites' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +) + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +const mockNotFoundError = { + owner: { + repository: { + __typename: 'NotFoundError', + message: 'repo not found', + }, + }, +} + +const mockIncorrectResponse = { + owner: { + repository: { + invalid: 'invalid', + }, + }, +} + +const mockResponse = { + owner: { + repository: { + __typename: 'Repository', + testAnalytics: { + testSuites: ['java', 'script', 'javascript'], + }, + }, + }, +} + +describe('useTestResultsTestSuites', () => { + function setup({ + isNotFoundError = false, + isUnsuccessfulParseError = false, + }) { + server.use( + graphql.query('GetTestResultsTestSuites', (info) => { + if (isNotFoundError) { + return HttpResponse.json({ data: mockNotFoundError }) + } else if (isUnsuccessfulParseError) { + return HttpResponse.json({ data: mockIncorrectResponse }) + } + return HttpResponse.json({ data: mockResponse }) + }) + ) + } + + describe('when called with successful res', () => { + it('returns the data', async () => { + setup({}) + const { result } = renderHook(() => useTestResultsTestSuites({}), { + wrapper, + }) + + await waitFor(() => result.current.isLoading) + await waitFor(() => !result.current.isLoading) + + await waitFor(() => + expect(result.current.data).toEqual({ + testSuites: ['java', 'script', 'javascript'], + }) + ) + }) + }) + + describe('when failed to parse data', () => { + let consoleSpy: MockInstance + beforeAll(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterAll(() => { + consoleSpy.mockRestore() + }) + + it('returns a failed to parse error', async () => { + setup({ isUnsuccessfulParseError: true }) + const { result } = renderHook(() => useTestResultsTestSuites({}), { + wrapper, + }) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + dev: 'useTestResultsTestSuites - 404 Failed to parse data', + }) + ) + ) + }) + }) + + describe('when data not found', () => { + let consoleSpy: MockInstance + beforeAll(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterAll(() => { + consoleSpy.mockRestore() + }) + + it('returns a not found error', async () => { + setup({ isNotFoundError: true }) + const { result } = renderHook(() => useTestResultsTestSuites({}), { + wrapper, + }) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + data: {}, + }) + ) + ) + }) + }) +}) diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsTestSuites/useTestResultsTestSuites.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsTestSuites/useTestResultsTestSuites.tsx new file mode 100644 index 0000000000..fdc8159082 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/hooks/useTestResultsTestSuites/useTestResultsTestSuites.tsx @@ -0,0 +1,97 @@ +import { useQuery } from '@tanstack/react-query' +import { useParams } from 'react-router-dom' +import { z } from 'zod' + +import { RepoNotFoundErrorSchema } from 'services/repo' +import Api from 'shared/api' +import { NetworkErrorObject } from 'shared/api/helpers' + +const TestResultsTestSuitesSchema = z.object({ + owner: z + .object({ + repository: z.discriminatedUnion('__typename', [ + z.object({ + __typename: z.literal('Repository'), + testAnalytics: z + .object({ + testSuites: z.array(z.string()), + }) + .nullable(), + }), + RepoNotFoundErrorSchema, + ]), + }) + .nullable(), +}) + +const query = ` + query GetTestResultsTestSuites( + $owner: String! + $repo: String! + $term: String + ) { + owner(username: $owner) { + repository: repository(name: $repo) { + __typename + ... on Repository { + testAnalytics { + testSuites(term: $term) + } + } + ... on NotFoundError { + message + } + } + } + } + ` + +interface URLParams { + provider: string + owner: string + repo: string +} + +export const useTestResultsTestSuites = ({ term }: { term?: string }) => { + const { provider, owner, repo } = useParams() + + return useQuery({ + queryKey: ['GetTestResultsTestSuites', provider, owner, repo, term], + queryFn: ({ signal }) => + Api.graphql({ + provider, + query, + signal, + variables: { + provider, + owner, + repo, + term, + }, + }).then((res) => { + const parsedData = TestResultsTestSuitesSchema.safeParse(res?.data) + + if (!parsedData.success) { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useTestResultsTestSuites - 404 Failed to parse data', + } satisfies NetworkErrorObject) + } + + const data = parsedData.data + + if (data?.owner?.repository?.__typename === 'NotFoundError') { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useTestResultsTestSuites - 404 Not found error', + } satisfies NetworkErrorObject) + } + + return { + testSuites: data?.owner?.repository?.testAnalytics?.testSuites, + } + }), + }) +} diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/index.ts b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/index.ts new file mode 100644 index 0000000000..f5d31286f8 --- /dev/null +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsPage/index.ts @@ -0,0 +1 @@ +export { default } from './FailedTestsPage' diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsTab.test.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsTab.test.tsx index 9a53428329..a8d0e08ce7 100644 --- a/src/pages/RepoPage/FailedTestsTab/FailedTestsTab.test.tsx +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsTab.test.tsx @@ -18,7 +18,7 @@ vi.mock('./GitHubActions', () => ({ vi.mock('./CodecovCLI', () => ({ default: () => 'Codecov CLI tab', })) -vi.mock('./FailedTestsTable/FailedTestsTable.tsx', () => ({ +vi.mock('./FailedTestsTable/FailedTestsTable', () => ({ default: () => 'Failed Tests Table', })) vi.mock('./FailedTestsTable/BranchSelector', () => ({ @@ -27,6 +27,9 @@ vi.mock('./FailedTestsTable/BranchSelector', () => ({ vi.mock('../ActivationAlert', () => ({ default: () => 'Activation Alert', })) +vi.mock('./FailedTestsPage/FailedTestsPage', () => ({ + default: () => 'Failed Tests Page', +})) vi.mock('shared/useRedirect', async () => { const actual = await vi.importActual('shared/useRedirect') @@ -251,21 +254,12 @@ describe('FailedTestsTab', () => { expect(content).toBeInTheDocument() }) - it('renders Failed Tests Table', async () => { + it('renders Failed Tests Page', async () => { setup({ testEnabled: true }) render(, { wrapper: wrapper('/gh/codecov/cool-repo/tests'), }) - const content = await screen.findByText(/Failed Tests Table/) - expect(content).toBeInTheDocument() - }) - - it('renders Branch Selector', async () => { - setup({ testEnabled: true }) - render(, { - wrapper: wrapper('/gh/codecov/cool-repo/tests'), - }) - const content = await screen.findByText(/Branch Selector/) + const content = await screen.findByText(/Failed Tests Page/) expect(content).toBeInTheDocument() }) }) @@ -285,7 +279,7 @@ describe('FailedTestsTab', () => { expect(activationAlert).toBeInTheDocument() }) - it('renders failed tests table if public repo', async () => { + it('renders failed tests page if public repo', async () => { setup({ testEnabled: true, isCurrentUserActivated: false, @@ -295,8 +289,8 @@ describe('FailedTestsTab', () => { wrapper: wrapper('/gh/codecov/cool-repo/tests'), }) - const activationAlert = await screen.findByText(/Failed Tests Table/) - expect(activationAlert).toBeInTheDocument() + const page = await screen.findByText(/Failed Tests Page/) + expect(page).toBeInTheDocument() }) }) }) diff --git a/src/pages/RepoPage/FailedTestsTab/FailedTestsTab.tsx b/src/pages/RepoPage/FailedTestsTab/FailedTestsTab.tsx index 912f9ea09e..e13d8c2bba 100644 --- a/src/pages/RepoPage/FailedTestsTab/FailedTestsTab.tsx +++ b/src/pages/RepoPage/FailedTestsTab/FailedTestsTab.tsx @@ -16,8 +16,7 @@ import { RadioTileGroup } from 'ui/RadioTileGroup' import Spinner from 'ui/Spinner' import CodecovCLI from './CodecovCLI' -import BranchSelector from './FailedTestsTable/BranchSelector' -import FailedTestsTable from './FailedTestsTable/FailedTestsTable' +import FailedTestsPage from './FailedTestsPage' import GitHubActions from './GitHubActions' import ActivationAlert from '../ActivationAlert' @@ -140,10 +139,7 @@ function FailedTestsTab() { ) : ( }> -
- - -
+
)} diff --git a/src/pages/RepoPage/shared/constants.ts b/src/pages/RepoPage/shared/constants.ts index cf3d55efc4..7e921b8c36 100644 --- a/src/pages/RepoPage/shared/constants.ts +++ b/src/pages/RepoPage/shared/constants.ts @@ -4,6 +4,16 @@ export const MEASUREMENT_INTERVAL = { INTERVAL_1_DAY: 'INTERVAL_1_DAY', } as const +export type MeasurementInterval = keyof typeof MEASUREMENT_INTERVAL + +export const MeasurementTimeOptions = [ + { label: 'Last 30 days', value: MEASUREMENT_INTERVAL.INTERVAL_30_DAY }, + { label: 'Last 7 days', value: MEASUREMENT_INTERVAL.INTERVAL_7_DAY }, + { label: 'Last day', value: MEASUREMENT_INTERVAL.INTERVAL_1_DAY }, +] as const + +export type MeasurementTimeOption = (typeof MeasurementTimeOptions)[number] + export const MEASUREMENT_TIME_INTERVALS = { ALL_TIME: MEASUREMENT_INTERVAL.INTERVAL_30_DAY, LAST_6_MONTHS: MEASUREMENT_INTERVAL.INTERVAL_7_DAY, diff --git a/src/services/navigation/useNavLinks/useStaticNavLinks.js b/src/services/navigation/useNavLinks/useStaticNavLinks.js index f51c528457..2ea03994de 100644 --- a/src/services/navigation/useNavLinks/useStaticNavLinks.js +++ b/src/services/navigation/useNavLinks/useStaticNavLinks.js @@ -463,6 +463,13 @@ export function useStaticNavLinks() { isExternalLink: true, openNewTab: true, }, + testsAnalyticsDataRetention: { + text: 'Test Analytics Data Retention', + path: () => + 'https://docs.codecov.com/docs/test-result-ingestion-beta#data-retention', + isExternalLink: true, + openNewTab: true, + }, expiredReports: { text: 'Expired Reports', path: () => diff --git a/src/services/navigation/useNavLinks/useStaticNavLinks.test.jsx b/src/services/navigation/useNavLinks/useStaticNavLinks.test.jsx index 0403a33d7f..2a975c383c 100644 --- a/src/services/navigation/useNavLinks/useStaticNavLinks.test.jsx +++ b/src/services/navigation/useNavLinks/useStaticNavLinks.test.jsx @@ -90,6 +90,7 @@ describe('useStaticNavLinks', () => { ${links.installSelfHosted} | ${'https://docs.codecov.com/docs/installing-codecov-self-hosted'} ${links.login} | ${`/login`} ${links.testsAnalytics} | ${'https://docs.codecov.com/docs/test-result-ingestion-beta#failed-test-reporting'} + ${links.testsAnalyticsDataRetention} | ${'https://docs.codecov.com/docs/test-result-ingestion-beta#data-retention'} ${links.expiredReports} | ${'https://docs.codecov.com/docs/codecov-yaml#section-expired-reports'} ${links.unusableReports} | ${'https://docs.codecov.com/docs/error-reference#unusable-reports'} ${links.demoRepo} | ${'/github/codecov/gazebo'} diff --git a/src/shared/GlobalTopBanners/TokenlessBanner/TokenRequiredBanner/TokenRequiredBanner.tsx b/src/shared/GlobalTopBanners/TokenlessBanner/TokenRequiredBanner/TokenRequiredBanner.tsx index c83b18dd14..39e639944d 100644 --- a/src/shared/GlobalTopBanners/TokenlessBanner/TokenRequiredBanner/TokenRequiredBanner.tsx +++ b/src/shared/GlobalTopBanners/TokenlessBanner/TokenRequiredBanner/TokenRequiredBanner.tsx @@ -84,7 +84,7 @@ const AdminTokenRequiredBanner: React.FC = () => {

- You must now upload using a token. + You must now upload using a token Upload with either{' '} {typeof orgUploadToken === 'string' ? ( diff --git a/src/shared/utils/dates.js b/src/shared/utils/dates.js index 518039bc48..14245b6e9e 100644 --- a/src/shared/utils/dates.js +++ b/src/shared/utils/dates.js @@ -1,4 +1,10 @@ -import { format, formatDistanceToNow, fromUnixTime, parseISO } from 'date-fns' +import { + format, + formatDistanceToNow, + fromUnixTime, + intervalToDuration, + parseISO, +} from 'date-fns' import { useMemo } from 'react' export function useDateFormatted(date, formatDescription = 'MMMM do yyyy') { @@ -18,3 +24,21 @@ export function formatTimeToNow(date) { addSuffix: true, }) } + +export const formatTimeFromSeconds = (totalSeconds) => { + if (totalSeconds === 0) return '0s' + if (!totalSeconds) return 'N/A' + + const duration = intervalToDuration({ start: 0, end: totalSeconds * 1000 }) + + const { days, hours, minutes, seconds } = duration + + return [ + days ? `${days}d` : '', + hours ? `${hours}h` : '', + minutes ? `${minutes}m` : '', + seconds ? `${seconds}s` : '', + ] + .filter(Boolean) + .join(' ') +} diff --git a/src/shared/utils/dates.test.js b/src/shared/utils/dates.test.js index 9c83b4cdee..ef16d1c80b 100644 --- a/src/shared/utils/dates.test.js +++ b/src/shared/utils/dates.test.js @@ -1,6 +1,10 @@ import { renderHook } from '@testing-library/react' -import { formatTimeToNow, useDateFormatted } from './dates' +import { + formatTimeFromSeconds, + formatTimeToNow, + useDateFormatted, +} from './dates' describe('useDateFormatted and formatTimeToNow functions', () => { let formattedDate @@ -68,3 +72,21 @@ describe('useDateFormatted and formatTimeToNow functions', () => { }) }) }) + +describe('formatTimeFromSeconds', () => { + it('returns "N/A" when totalSeconds is null', () => { + expect(formatTimeFromSeconds(null)).toBe('N/A') + }) + + it('returns "N/A" when totalSeconds is undefined', () => { + expect(formatTimeFromSeconds(undefined)).toBe('N/A') + }) + + it('returns "0s" when totalSeconds is 0', () => { + expect(formatTimeFromSeconds(0)).toBe('0s') + }) + + it('returns the correct time format when totalSeconds is greater than 0', () => { + expect(formatTimeFromSeconds(3661)).toBe('1h 1m 1s') + }) +}) diff --git a/src/shared/utils/testingTests.test.ts b/src/shared/utils/testingTests.test.ts new file mode 100644 index 0000000000..f0f8ee7da0 --- /dev/null +++ b/src/shared/utils/testingTests.test.ts @@ -0,0 +1,53 @@ +import { checkForConsecutiveMatches, outerFunction } from './testingTests' + +describe('checkForConsecutiveMatches', () => { + it('should return true sometimes', () => { + expect(checkForConsecutiveMatches()).toBe(true) + }) + + it('should return false sometimes', () => { + expect(checkForConsecutiveMatches()).toBe(false) + }) + + it('should return true 50% of the time over 100 runs', () => { + let trueCount = 0 + let falseCount = 0 + for (let i = 0; i < 100; i++) { + if (checkForConsecutiveMatches()) trueCount++ + else falseCount++ + } + expect(trueCount).toBeGreaterThan(49) + expect(falseCount).toBeGreaterThan(49) + }) + + it('should always return a boolean', () => { + const result = checkForConsecutiveMatches() + expect(typeof result).toBe('boolean') + }) + + it('this test should pass', () => { + const nums = [1, 2, 3] + expect(outerFunction(nums)).toBe(6) + }) + + it('this test should pass too', () => { + const nums = [1, 2, 3, 4] + expect(outerFunction(nums)).not.toBe( + nums.reduce((sum, num) => sum + num, 0) + ) + }) + + it('this test should pass as well', () => { + const nums = [10, 20, 30, 40] + + const expectedSum = + // @ts-expect-error + nums.reduce((sum, num) => sum + num, 0) - nums[nums.length - 1] + expect(outerFunction(nums)).toBe(expectedSum) + }) + + it('sum of array with only one element should be correct', () => { + const nums = [42] + expect(outerFunction(nums)).toBe(42) // Expected sum: 42 + }) +}) diff --git a/src/shared/utils/testingTests.ts b/src/shared/utils/testingTests.ts new file mode 100644 index 0000000000..712cf16247 --- /dev/null +++ b/src/shared/utils/testingTests.ts @@ -0,0 +1,23 @@ +function generateFooOrBar(): string { + const num = Math.floor(Math.random() * 100) + 1 + return num % 2 === 0 ? 'foo' : 'bar' +} + +export function checkForConsecutiveMatches(): boolean { + const firstResult = generateFooOrBar() + const secondResult = generateFooOrBar() + return firstResult === secondResult +} + +export function calculateSum(arr: number[]): number { + // Off-by-one error: sums the array but skips the last element by slicing to -1 + return arr.slice(0, -1).reduce((sum, num) => sum + num, 0) +} + +export function innerFunction(nums: number[]): number { + return calculateSum(nums) +} + +export function outerFunction(nums: number[]): number { + return innerFunction(nums) +}