From ed3d9d04f4b55cf76f4159a31235a84d7efe74d0 Mon Sep 17 00:00:00 2001 From: nicholas-codecov Date: Tue, 10 Sep 2024 09:48:51 -0300 Subject: [PATCH 1/2] add in new hook to fetch paginated list of bundles --- .../bundleAnalysis/usePagedBundleAssets.tsx | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 src/services/bundleAnalysis/usePagedBundleAssets.tsx diff --git a/src/services/bundleAnalysis/usePagedBundleAssets.tsx b/src/services/bundleAnalysis/usePagedBundleAssets.tsx new file mode 100644 index 0000000000..aa374a604e --- /dev/null +++ b/src/services/bundleAnalysis/usePagedBundleAssets.tsx @@ -0,0 +1,335 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { z } from 'zod' + +import { MissingHeadReportSchema } from 'services/comparison' +import { + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, +} from 'services/repo' +import Api from 'shared/api' +import { type NetworkErrorObject } from 'shared/api/helpers' +import { mapEdges } from 'shared/utils/graphql' +import A from 'ui/A' + +const PageInfoSchema = z.object({ + hasNextPage: z.boolean(), + endCursor: z.string().nullable(), +}) + +const BundleDataSchema = z.object({ + loadTime: z.object({ + threeG: z.number(), + highSpeed: z.number(), + }), + size: z.object({ + gzip: z.number(), + uncompress: z.number(), + }), +}) + +const AssetMeasurementsSchema = z.object({ + change: z + .object({ + size: z.object({ + uncompress: z.number(), + }), + }) + .nullable(), + measurements: z + .array( + z.object({ + timestamp: z.string(), + avg: z.number().nullable(), + }) + ) + .nullable(), +}) + +const BundleAssetSchema = z.object({ + name: z.string(), + extension: z.string(), + bundleData: BundleDataSchema, + measurements: AssetMeasurementsSchema.nullable(), +}) + +const BundleAssetPaginatedSchema = z.object({ + edges: z.array(z.object({ node: BundleAssetSchema })), + pageInfo: PageInfoSchema, +}) + +const BundleAnalysisReportSchema = z.object({ + __typename: z.literal('BundleAnalysisReport'), + bundle: z + .object({ + bundleData: z.object({ + size: z.object({ + uncompress: z.number(), + }), + }), + assetsPaginated: BundleAssetPaginatedSchema.nullable(), + }) + .nullable(), +}) + +const BundleReportSchema = z.discriminatedUnion('__typename', [ + BundleAnalysisReportSchema, + MissingHeadReportSchema, +]) + +const RepositorySchema = z.object({ + __typename: z.literal('Repository'), + branch: z + .object({ + head: z + .object({ + bundleAnalysisReport: BundleReportSchema.nullable(), + }) + .nullable(), + }) + .nullable(), +}) + +const RequestSchema = z.object({ + owner: z + .object({ + repository: z + .discriminatedUnion('__typename', [ + RepositorySchema, + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, + ]) + .nullable(), + }) + .nullable(), +}) + +const query = ` +query PagedBundleAssets( + $owner: String! + $repo: String! + $branch: String! + $bundle: String! + $interval: MeasurementInterval! + $dateBefore: DateTime! + $dateAfter: DateTime + $filters: BundleAnalysisReportFilters + $assetsAfter: String + $orderingDirection: OrderingDirection + $ordering: AssetOrdering +) { + owner(username: $owner) { + repository(name: $repo) { + __typename + ... on Repository { + branch(name: $branch) { + head { + bundleAnalysisReport { + __typename + ... on BundleAnalysisReport { + bundle(name: $bundle, filters: $filters) { + bundleData { + size { + uncompress + } + } + assetsPaginated( + first: 20 + after: $assetsAfter + orderingDirection: $orderingDirection + ordering: $ordering + ) { + edges { + node { + name + extension + bundleData { + loadTime { + threeG + highSpeed + } + size { + uncompress + gzip + } + } + measurements( + interval: $interval + before: $dateBefore + after: $dateAfter + branch: $branch + ) { + change { + size { + uncompress + } + } + measurements { + timestamp + avg + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + ... on MissingHeadReport { + message + } + } + } + } + } + ... on NotFoundError { + message + } + ... on OwnerNotActivatedError { + message + } + } + } +}` + +interface UsePagedBundleAssetsArgs { + provider: string + owner: string + repo: string + branch: string + bundle: string + interval?: 'INTERVAL_1_DAY' | 'INTERVAL_7_DAY' | 'INTERVAL_30_DAY' + dateBefore?: Date + dateAfter?: Date | null + filters?: { + reportGroups?: string[] + loadTypes?: string[] + } + orderingDirection: 'ASC' | 'DESC' + ordering: 'NAME' | 'SIZE' | 'TYPE' + opts?: { + enabled?: boolean + suspense?: boolean + } +} + +export const usePagedBundleAssets = ({ + provider, + owner, + repo, + branch, + bundle, + interval, + dateBefore, + dateAfter, + filters = {}, + orderingDirection, + ordering, + opts, +}: UsePagedBundleAssetsArgs) => { + const { data, ...rest } = useInfiniteQuery({ + queryKey: [ + 'PagedBundleAssets', + provider, + owner, + repo, + branch, + bundle, + interval, + dateBefore, + dateAfter, + filters, + ordering, + orderingDirection, + ], + queryFn: ({ signal, pageParam }) => + Api.graphql({ + query, + provider, + signal, + variables: { + owner, + repo, + branch, + bundle, + interval, + dateBefore, + dateAfter, + filters, + assetsAfter: pageParam, + ordering, + orderingDirection, + }, + }).then((res) => { + const parsedData = RequestSchema.safeParse(res?.data) + + if (!parsedData.success) { + return Promise.reject({ + status: 404, + data: {}, + dev: 'usePagedBundleAssets - 404 schema parsing failed', + } satisfies NetworkErrorObject) + } + + const data = parsedData.data + + if (data?.owner?.repository?.__typename === 'NotFoundError') { + return Promise.reject({ + status: 404, + data: {}, + }) + } + + if (data?.owner?.repository?.__typename === 'OwnerNotActivatedError') { + return Promise.reject({ + status: 403, + data: { + detail: ( +

+ Activation is required to view this repo, please{' '} + {/* @ts-expect-error */} + click here to activate + your account. +

+ ), + }, + }) + } + + if ( + !data?.owner || + data?.owner?.repository?.branch?.head?.bundleAnalysisReport + ?.__typename === 'MissingHeadReport' + ) { + return { + assets: [], + pageInfo: null, + } + } + + const assets = mapEdges( + data?.owner?.repository?.branch?.head?.bundleAnalysisReport?.bundle + ?.assetsPaginated + ) + + return { + assets, + pageInfo: + data?.owner?.repository?.branch?.head?.bundleAnalysisReport?.bundle + ?.assetsPaginated?.pageInfo ?? null, + } + }), + getNextPageParam: (data) => { + return data?.pageInfo?.hasNextPage ? data?.pageInfo?.endCursor : undefined + }, + enabled: opts?.enabled !== undefined ? opts.enabled : true, + suspense: !!opts?.suspense, + }) + + return { + data: { assets: data?.pages.map((page) => page.assets).flat() }, + ...rest, + } +} From 97ffb87f29edb902f7d50a2fe2c24dbb806ee2ae Mon Sep 17 00:00:00 2001 From: nicholas-codecov Date: Tue, 10 Sep 2024 09:49:01 -0300 Subject: [PATCH 2/2] add in tests for new hook --- .../usePagedBundleAssets.spec.tsx | 457 ++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 src/services/bundleAnalysis/usePagedBundleAssets.spec.tsx diff --git a/src/services/bundleAnalysis/usePagedBundleAssets.spec.tsx b/src/services/bundleAnalysis/usePagedBundleAssets.spec.tsx new file mode 100644 index 0000000000..dd5fe0704f --- /dev/null +++ b/src/services/bundleAnalysis/usePagedBundleAssets.spec.tsx @@ -0,0 +1,457 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql } from 'msw' +import { setupServer } from 'msw/node' + +import { usePagedBundleAssets } from './usePagedBundleAssets' + +const node1 = { + name: 'asset-1', + extension: 'js', + bundleData: { + loadTime: { + threeG: 1, + highSpeed: 2, + }, + size: { + uncompress: 3, + gzip: 4, + }, + }, + measurements: { + change: { + size: { + uncompress: 5, + }, + }, + measurements: [{ timestamp: '2022-10-10T11:59:59', avg: 6 }], + }, +} + +const node2 = { + name: 'asset-2', + extension: 'js', + bundleData: { + loadTime: { + threeG: 1, + highSpeed: 2, + }, + size: { + uncompress: 3, + gzip: 4, + }, + }, + measurements: { + change: { + size: { + uncompress: 5, + }, + }, + measurements: [{ timestamp: '2022-10-10T11:59:59', avg: 6 }], + }, +} + +const node3 = { + name: 'asset-3', + extension: 'js', + bundleData: { + loadTime: { + threeG: 1, + highSpeed: 2, + }, + size: { + uncompress: 3, + gzip: 4, + }, + }, + measurements: { + change: { + size: { + uncompress: 5, + }, + }, + measurements: [{ timestamp: '2022-10-10T11:59:59', avg: 6 }], + }, +} + +const mockMissingHeadReport = { + owner: { + repository: { + __typename: 'Repository', + branch: { + head: { + bundleAnalysisReport: { + __typename: 'MissingHeadReport', + message: 'Missing head report', + }, + }, + }, + }, + }, +} + +const mockUnsuccessfulParseError = {} + +const mockNullOwner = { owner: null } + +const mockRepoNotFound = { + owner: { + repository: { + __typename: 'NotFoundError', + message: 'Repository not found', + }, + }, +} + +const mockOwnerNotActivated = { + owner: { + repository: { + __typename: 'OwnerNotActivatedError', + message: 'Owner not activated', + }, + }, +} + +const server = setupServer() +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const wrapper: React.FC = ({ children }) => ( + {children} +) + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + jest.resetAllMocks() + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +interface SetupArgs { + isNotFoundError?: boolean + isOwnerNotActivatedError?: boolean + isUnsuccessfulParseError?: boolean + isNullOwner?: boolean + missingHeadReport?: boolean +} + +describe('usePagedBundleAssets', () => { + function setup({ + isNotFoundError = false, + isOwnerNotActivatedError = false, + isUnsuccessfulParseError = false, + isNullOwner = false, + missingHeadReport = false, + }: SetupArgs) { + const passedBranch = jest.fn() + const madeRequest = jest.fn() + + server.use( + graphql.query('PagedBundleAssets', (req, res, ctx) => { + madeRequest() + if (req.variables?.branch) { + passedBranch(req.variables?.branch) + } + + if (isNotFoundError) { + return res(ctx.status(200), ctx.data(mockRepoNotFound)) + } else if (isOwnerNotActivatedError) { + return res(ctx.status(200), ctx.data(mockOwnerNotActivated)) + } else if (isUnsuccessfulParseError) { + return res(ctx.status(200), ctx.data(mockUnsuccessfulParseError)) + } else if (isNullOwner) { + return res(ctx.status(200), ctx.data(mockNullOwner)) + } else if (missingHeadReport) { + return res(ctx.status(200), ctx.data(mockMissingHeadReport)) + } + + return res( + ctx.status(200), + ctx.data({ + owner: { + repository: { + __typename: 'Repository', + branch: { + head: { + bundleAnalysisReport: { + __typename: 'BundleAnalysisReport', + bundle: { + bundleData: { + size: { + uncompress: 12, + }, + }, + assetsPaginated: { + edges: req.variables.assetsAfter + ? [{ node: node3 }] + : [{ node: node1 }, { node: node2 }], + pageInfo: { + hasNextPage: req.variables.assetsAfter + ? false + : true, + endCursor: req.variables.assetsAfter + ? 'aa' + : 'MjAyMC0wOC0xMSAxNzozMDowMiswMDowMHwxMDA=', + }, + }, + }, + }, + }, + }, + }, + }, + }) + ) + }) + ) + + return { passedBranch, madeRequest } + } + + describe('when __typename is Repository', () => { + it('returns expected asset nodes', async () => { + setup({}) + const { result } = renderHook( + () => + usePagedBundleAssets({ + provider: 'gh', + owner: 'codecov', + repo: 'codecov', + branch: 'main', + bundle: 'test-bundle', + ordering: 'NAME', + orderingDirection: 'ASC', + }), + { + wrapper, + } + ) + + await waitFor(() => { + expect(result.current.data?.assets).toEqual([node1, node2]) + }) + }) + + describe('calling next page', () => { + it('adds in the next page of assets', async () => { + setup({}) + const { result } = renderHook( + () => + usePagedBundleAssets({ + provider: 'gh', + owner: 'codecov', + repo: 'codecov', + branch: 'main', + bundle: 'test-bundle', + ordering: 'NAME', + orderingDirection: 'ASC', + }), + { + wrapper, + } + ) + + await waitFor(() => { + expect(result.current.data?.assets).toEqual([node1, node2]) + }) + + result.current.fetchNextPage() + + await waitFor(() => result.current.isFetching) + await waitFor(() => !result.current.isFetching) + + await waitFor(() => + expect(result.current.data.assets).toEqual([node1, node2, node3]) + ) + }) + }) + + describe('there is a missing head report', () => { + it('returns an empty array', async () => { + setup({ missingHeadReport: true }) + const { result } = renderHook( + () => + usePagedBundleAssets({ + provider: 'gh', + owner: 'codecov', + repo: 'codecov', + branch: 'main', + bundle: 'test-bundle', + ordering: 'NAME', + orderingDirection: 'ASC', + }), + { + wrapper, + } + ) + + await waitFor(() => expect(result.current.isLoading).toBeTruthy()) + await waitFor(() => expect(result.current.isLoading).toBeFalsy()) + + await waitFor(() => { + expect(result.current.data).toEqual({ assets: [] }) + }) + }) + }) + }) + + describe('owner is null', () => { + it('returns an empty array', async () => { + setup({ isNullOwner: true }) + const { result } = renderHook( + () => + usePagedBundleAssets({ + provider: 'gh', + owner: 'codecov', + repo: 'codecov', + branch: 'main', + bundle: 'test-bundle', + ordering: 'NAME', + orderingDirection: 'ASC', + }), + { + wrapper, + } + ) + + await waitFor(() => expect(result.current.isLoading).toBeTruthy()) + await waitFor(() => expect(result.current.isLoading).toBeFalsy()) + + await waitFor(() => { + expect(result.current.data).toEqual({ assets: [] }) + }) + }) + }) + + describe('when __typename is NotFoundError', () => { + let oldConsoleError = console.error + + beforeEach(() => { + console.error = () => null + }) + + afterEach(() => { + console.error = oldConsoleError + }) + + it('throws a 404', async () => { + setup({ isNotFoundError: true }) + const { result } = renderHook( + () => + usePagedBundleAssets({ + provider: 'gh', + owner: 'codecov', + repo: 'codecov', + branch: 'main', + bundle: 'test-bundle', + ordering: 'NAME', + orderingDirection: 'ASC', + }), + { + wrapper, + } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + }) + ) + ) + }) + }) + + describe('when __typename is OwnerNotActivatedError', () => { + let oldConsoleError = console.error + + beforeEach(() => { + console.error = () => null + }) + + afterEach(() => { + console.error = oldConsoleError + }) + + it('throws a 403', async () => { + setup({ isOwnerNotActivatedError: true }) + const { result } = renderHook( + () => + usePagedBundleAssets({ + provider: 'gh', + owner: 'codecov', + repo: 'codecov', + branch: 'main', + bundle: 'test-bundle', + ordering: 'NAME', + orderingDirection: 'ASC', + }), + { + wrapper, + } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 403, + }) + ) + ) + }) + }) + + describe('unsuccessful parse error', () => { + let oldConsoleError = console.error + + beforeEach(() => { + console.error = () => null + }) + + afterEach(() => { + console.error = oldConsoleError + }) + + it('throws a 404', async () => { + setup({ isUnsuccessfulParseError: true }) + const { result } = renderHook( + () => + usePagedBundleAssets({ + provider: 'gh', + owner: 'codecov', + repo: 'codecov', + branch: 'main', + bundle: 'test-bundle', + ordering: 'NAME', + orderingDirection: 'ASC', + }), + { + wrapper, + } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + }) + ) + ) + }) + }) +})