From 6e91494b16983d005f3b3c60d6baba5d85c10425 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Thu, 8 May 2025 12:23:37 +0200 Subject: [PATCH] Add coveralls integration (#35424) * update gh action, update vitest config * debug * debug cov * idk try something different * test2 * test3 * add base path * rm debug * add apiAuthenticate tests * supabaseClient tests * apiWrappers tests * add apiHelpers tests * add configcat tests * add formatSql tests * add github tests * add cloudprovider utils tests * add helpers tests * fix typeerr * add missing readonly err * fix typeerrrs * fix type errors in apiWrapper tests * fix apiHelpers test * add packages/ui tests * add coveralls flags * try coveralls parallel config * fix coveralls parallel config --------- Co-authored-by: Joshen Lim --- .github/workflows/studio-unit-tests.yml | 29 +- .github/workflows/ui-tests.yml | 28 +- apps/studio/lib/api/apiAuthenticate.test.ts | 318 ++++++++++++++++ apps/studio/lib/api/apiAuthenticate.ts | 8 +- apps/studio/lib/api/apiHelpers.test.ts | 132 +++++++ apps/studio/lib/api/apiWrappers.test.ts | 41 +++ apps/studio/lib/api/supabaseClient.test.ts | 49 +++ apps/studio/lib/cloudprovider-utils.test.ts | 22 ++ apps/studio/lib/configcat.test.ts | 39 ++ apps/studio/lib/formatSql.test.ts | 19 + apps/studio/lib/github.test.ts | 21 ++ apps/studio/lib/{github.tsx => github.ts} | 0 apps/studio/lib/helpers.test.ts | 346 ++++++++++++++++++ apps/studio/lib/helpers.ts | 3 +- apps/studio/package.json | 6 +- .../{vitest.config.mts => vitest.config.ts} | 6 + packages/ui/package.json | 5 +- packages/ui/vitest.config.ts | 6 + pnpm-lock.yaml | 45 ++- 19 files changed, 1108 insertions(+), 15 deletions(-) create mode 100644 apps/studio/lib/api/apiAuthenticate.test.ts create mode 100644 apps/studio/lib/api/apiHelpers.test.ts create mode 100644 apps/studio/lib/api/apiWrappers.test.ts create mode 100644 apps/studio/lib/api/supabaseClient.test.ts create mode 100644 apps/studio/lib/cloudprovider-utils.test.ts create mode 100644 apps/studio/lib/configcat.test.ts create mode 100644 apps/studio/lib/formatSql.test.ts create mode 100644 apps/studio/lib/github.test.ts rename apps/studio/lib/{github.tsx => github.ts} (100%) create mode 100644 apps/studio/lib/helpers.test.ts rename apps/studio/{vitest.config.mts => vitest.config.ts} (87%) diff --git a/.github/workflows/studio-unit-tests.yml b/.github/workflows/studio-unit-tests.yml index 317ea8dab14..de776aa2630 100644 --- a/.github/workflows/studio-unit-tests.yml +++ b/.github/workflows/studio-unit-tests.yml @@ -24,9 +24,12 @@ permissions: contents: read jobs: - check: + test: # Uses larger hosted runner as it significantly decreases build times runs-on: [larger-runner-4cpu] + strategy: + matrix: + test_number: [1] steps: - uses: actions/checkout@v4 @@ -50,5 +53,25 @@ jobs: env: # Default is 2 GB, increase to have less frequent OOM errors NODE_OPTIONS: '--max_old_space_size=3072' - run: pnpm run test:studio - working-directory: ./ + run: pnpm run test:ci + working-directory: ./apps/studio + + - name: Upload coverage results to Coveralls + uses: coverallsapp/github-action@master + with: + parallel: true + flag-name: studio-tests + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: ./apps/studio/coverage/lcov.info + base-path: './apps/studio' + + finish: + needs: test + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: coverallsapp/github-action@master + with: + parallel-finished: true + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index c579d052023..6284f0cae2b 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -15,8 +15,11 @@ permissions: contents: read jobs: - build: + test: runs-on: ubuntu-latest + strategy: + matrix: + test_number: [1] steps: - uses: actions/checkout@v4 @@ -39,4 +42,25 @@ jobs: run: pnpm i - name: Run tests - run: pnpm run test:ui + run: pnpm run test:ci + working-directory: ./packages/ui + + - name: Upload coverage results to Coveralls + uses: coverallsapp/github-action@master + with: + parallel: true + flag-name: ui-tests + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: ./packages/ui/coverage/lcov.info + base-path: './packages/ui' + + finish: + needs: test + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: coverallsapp/github-action@master + with: + parallel-finished: true + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/studio/lib/api/apiAuthenticate.test.ts b/apps/studio/lib/api/apiAuthenticate.test.ts new file mode 100644 index 00000000000..419ed672512 --- /dev/null +++ b/apps/studio/lib/api/apiAuthenticate.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { apiAuthenticate } from './apiAuthenticate' +import { readOnly } from './supabaseClient' + +const mocks = vi.hoisted(() => { + return { + getAuthUser: vi.fn().mockResolvedValue({ + user: { + id: 'test-gotrue-id', + email: 'test@example.com', + }, + error: null, + }), + getIdentity: vi.fn().mockReturnValue({ + identity: null, + error: null, + }), + getAuth0Id: vi.fn(), + } +}) + +// Mock dependencies +vi.mock('./supabaseClient', () => ({ + readOnly: { + from: vi.fn(), + }, +})) + +vi.mock('lib/gotrue', () => ({ + getAuthUser: mocks.getAuthUser, + getIdentity: mocks.getIdentity, + getAuth0Id: mocks.getAuth0Id, +})) + +describe('apiAuthenticate', () => { + const mockReq = { + headers: { + authorization: 'Bearer test-token', + }, + query: {}, + } as any + + const mockRes = {} as any + + beforeEach(() => { + vi.clearAllMocks() + mocks.getAuthUser.mockResolvedValue({ + user: { + id: 'test-gotrue-id', + email: 'test@example.com', + }, + error: null, + }) + mocks.getIdentity.mockReturnValue({ + identity: null, + error: null, + }) + }) + + it('should return error when request is not available', async () => { + const result = await apiAuthenticate(null as any, mockRes) + expect(result).toStrictEqual({ error: new Error('Request is not available') }) + }) + + it('should return error when response is not available', async () => { + const result = await apiAuthenticate(mockReq, null as any) + expect(result).toStrictEqual({ error: new Error('Response is not available') }) + }) + + it('should return error when authorization token is missing', async () => { + const reqWithoutToken = { ...mockReq, headers: {} } + const result = await apiAuthenticate(reqWithoutToken, mockRes) + expect(result).toStrictEqual({ error: { name: 'Error', message: 'missing access token' } }) + }) + + it('should return error when auth user fetch fails', async () => { + mocks.getAuthUser.mockResolvedValue({ + user: null, + error: new Error('Auth failed'), + }) + + const result = await apiAuthenticate(mockReq, mockRes) + expect(result).toStrictEqual({ error: { name: 'Error', message: 'Auth failed' } }) + }) + + it('should handle identity error', async () => { + mocks.getIdentity.mockReturnValue({ + identity: null, + error: new Error('Identity error'), + }) + + const result = await apiAuthenticate(mockReq, mockRes) + expect(result).toStrictEqual({ error: { name: 'Error', message: 'Identity error' } }) + }) + + it('should set auth0 id when identity provider is present', async () => { + mocks.getIdentity.mockReturnValue({ + identity: { + provider: 'auth0', + id: 'auth0-id', + }, + error: null, + }) + mocks.getAuth0Id.mockReturnValue('auth0-user-id') + + // Mock user query + vi.mocked(readOnly.from).mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi + .fn() + .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }), + } as any) + + const result = await apiAuthenticate(mockReq, mockRes) + expect(result).toStrictEqual({ + id: 'test-user-id', + primary_email: 'test@example.com', + }) + expect(mocks.getAuth0Id).toHaveBeenCalledWith('auth0', 'auth0-id') + }) + + it('should return user when user_id_supabase is present', async () => { + // Mock identity to return auth0 provider + mocks.getIdentity.mockReturnValue({ + identity: { + provider: 'auth0', + id: 'auth0-id', + }, + error: null, + }) + mocks.getAuth0Id.mockReturnValue('auth0-user-id') + + // Mock user query to return a user with auth0_id + vi.mocked(readOnly.from).mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ + data: { + id: 'supabase-user-id', + auth0_id: 'auth0-user-id', + primary_email: 'test@example.com', + }, + }), + } as any) + + const result = await apiAuthenticate(mockReq, mockRes) + expect(result).toStrictEqual({ + id: 'supabase-user-id', + auth0_id: 'auth0-user-id', + primary_email: 'test@example.com', + }) + }) + + it('should return error when user does not exist', async () => { + vi.mocked(readOnly.from).mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: null }), + } as any) + + const result = await apiAuthenticate(mockReq, mockRes) + expect(result).toStrictEqual({ error: new Error('The user does not exist') }) + }) + + it('should check organization permissions when orgSlug is provided', async () => { + const reqWithOrg = { + ...mockReq, + query: { slug: 'test-org' }, + } + + // Mock user query + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi + .fn() + .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }), + } as any) + + // Mock organization query + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + match: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: { id: 'org-id' } }), + } as any) + + // Mock member check + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + match: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: { id: 'member-id' }, status: 200 }), + } as any) + + const result = await apiAuthenticate(reqWithOrg, mockRes) + expect(result).toStrictEqual({ + id: 'test-user-id', + primary_email: 'test@example.com', + }) + }) + + it('should return error when user lacks organization permissions', async () => { + const reqWithOrg = { + ...mockReq, + query: { slug: 'test-org' }, + } + + // Mock user query + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi + .fn() + .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }), + } as any) + + // Mock organization query + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + match: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: { id: 'org-id' } }), + } as any) + + // Mock member check failure + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + match: vi.fn().mockReturnThis(), + single: vi.fn().mockRejectedValue(new Error('Permission denied')), + } as any) + + const result = await apiAuthenticate(reqWithOrg, mockRes) + expect(result).toStrictEqual({ + error: { name: 'Error', message: 'The user does not have permission' }, + }) + }) + + it('should handle unknown errors gracefully', async () => { + mocks.getAuthUser.mockRejectedValue(new Error('Unexpected error')) + + const result = await apiAuthenticate(mockReq, mockRes) + expect(result).toStrictEqual({ error: { name: 'Error', message: 'Unexpected error' } }) + }) + + it('should get organization from project reference', async () => { + const reqWithProject = { + ...mockReq, + query: { ref: 'test-project' }, + } + + // Mock user query + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi + .fn() + .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }), + } as any) + + // Mock project query + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + match: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: { organization_id: 'org-id' } }), + } as any) + + // Mock member check + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + match: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: { id: 'member-id' }, status: 200 }), + } as any) + + const result = await apiAuthenticate(reqWithProject, mockRes) + expect(result).toStrictEqual({ + id: 'test-user-id', + primary_email: 'test@example.com', + }) + }) + + it('should use organization_id from projectRef in member check', async () => { + const reqWithProject = { + ...mockReq, + query: { ref: 'project-xyz' }, + } + + // Mock user query + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi + .fn() + .mockResolvedValue({ data: { id: 'user-123', primary_email: 'user@example.com' } }), + } as any) + + // Mock project query to return a specific organization_id + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + match: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: { organization_id: 'org-abc' } }), + } as any) + + // Spy on the match function for the member check + const matchSpy = vi.fn().mockReturnThis() + vi.mocked(readOnly.from).mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + match: matchSpy, + single: vi.fn().mockResolvedValue({ data: { id: 'member-1' }, status: 200 }), + } as any) + + const result = await apiAuthenticate(reqWithProject, mockRes) + expect(result).toStrictEqual({ + id: 'user-123', + primary_email: 'user@example.com', + }) + // Ensure the member check used the org id from the project lookup + expect(matchSpy).toHaveBeenCalledWith({ organization_id: 'org-abc', user_id: 'user-123' }) + }) +}) diff --git a/apps/studio/lib/api/apiAuthenticate.ts b/apps/studio/lib/api/apiAuthenticate.ts index f121dfa050a..392fc407ce9 100644 --- a/apps/studio/lib/api/apiAuthenticate.ts +++ b/apps/studio/lib/api/apiAuthenticate.ts @@ -19,18 +19,18 @@ export async function apiAuthenticate( res: NextApiResponse ): Promise> { if (!req) { - return { error: new Error('Request is not available') } as unknown as SupaResponse + return { error: new Error('Request is not available') } } if (!res) { - return { error: new Error('Response is not available') } as unknown as SupaResponse + return { error: new Error('Response is not available') } } const { slug: orgSlug, ref: projectRef } = req.query try { const user = await fetchUser(req, res) if (!user) { - return { error: new Error('The user does not exist') } as unknown as SupaResponse + return { error: new Error('The user does not exist') } } if (orgSlug || projectRef) await checkMemberPermission(req, user) @@ -38,7 +38,7 @@ export async function apiAuthenticate( return user } catch (error: any) { console.error('Error at apiAuthenticate', error) - return { error: { message: error.message ?? 'unknown' } } as unknown as SupaResponse + return { error: { name: 'Error', message: error.message ?? 'unknown' } } } } diff --git a/apps/studio/lib/api/apiHelpers.test.ts b/apps/studio/lib/api/apiHelpers.test.ts new file mode 100644 index 00000000000..992d24f379a --- /dev/null +++ b/apps/studio/lib/api/apiHelpers.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { constructHeaders, toSnakeCase } from './apiHelpers' + +vi.mock('lib/constants', () => ({ + IS_PLATFORM: false, +})) + +describe('apiHelpers', () => { + describe('constructHeaders', () => { + beforeEach(() => { + process.env.READ_ONLY_API_KEY = 'test-readonly-key' + process.env.SUPABASE_SERVICE_KEY = 'test-service-key' + }) + + it('should return default headers when no headers are provided', () => { + const result = constructHeaders(null as any) + expect(result).toEqual({ + 'Content-Type': 'application/json', + Accept: 'application/json', + }) + }) + + it('should clean and include only allowed headers', () => { + const inputHeaders = { + Accept: 'application/json', + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + 'x-connection-encrypted': 'true', + cookie: 'test-cookie', + 'User-Agent': 'test-agent', + Referer: 'test-referer', + } + + const result = constructHeaders(inputHeaders) + expect(result).toEqual({ + Accept: 'application/json', + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + 'x-connection-encrypted': 'true', + cookie: 'test-cookie', + apiKey: 'test-service-key', + }) + }) + + it('should remove undefined values from headers', () => { + const inputHeaders = { + Accept: undefined, + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + cookie: undefined, + } + + const result = constructHeaders(inputHeaders) + expect(result).toEqual({ + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + apiKey: 'test-service-key', + }) + }) + }) + + describe('toSnakeCase', () => { + it('should return null for null input', () => { + expect(toSnakeCase(null)).toBeNull() + }) + + it('should convert object keys to snake case', () => { + const input = { + firstName: 'John', + lastName: 'Doe', + contactInfo: { + emailAddress: 'john@example.com', + phoneNumber: '1234567890', + }, + } + + const expected = { + first_name: 'John', + last_name: 'Doe', + contact_info: { + email_address: 'john@example.com', + phone_number: '1234567890', + }, + } + + expect(toSnakeCase(input)).toEqual(expected) + }) + + it('should handle arrays of objects', () => { + const input = [ + { firstName: 'John', lastName: 'Doe' }, + { firstName: 'Jane', lastName: 'Smith' }, + ] + + const expected = [ + { first_name: 'John', last_name: 'Doe' }, + { first_name: 'Jane', last_name: 'Smith' }, + ] + + expect(toSnakeCase(input)).toEqual(expected) + }) + + it('should handle arrays of primitive values', () => { + const input = [1, 'test', true] + expect(toSnakeCase(input)).toEqual([1, 'test', true]) + }) + + it('should handle nested arrays', () => { + const input = { + users: [ + { firstName: 'John', contactInfo: { emailAddress: 'john@example.com' } }, + { firstName: 'Jane', contactInfo: { emailAddress: 'jane@example.com' } }, + ], + } + + const expected = { + users: [ + { first_name: 'John', contact_info: { email_address: 'john@example.com' } }, + { first_name: 'Jane', contact_info: { email_address: 'jane@example.com' } }, + ], + } + + expect(toSnakeCase(input)).toEqual(expected) + }) + + it('should handle primitive values', () => { + expect(toSnakeCase('test')).toBe('test') + expect(toSnakeCase(123)).toBe(123) + expect(toSnakeCase(true)).toBe(true) + }) + }) +}) diff --git a/apps/studio/lib/api/apiWrappers.test.ts b/apps/studio/lib/api/apiWrappers.test.ts new file mode 100644 index 00000000000..0768780ef28 --- /dev/null +++ b/apps/studio/lib/api/apiWrappers.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import apiWrapper from './apiWrapper' +import { apiAuthenticate } from './apiAuthenticate' + +vi.mock('lib/constants', () => ({ + IS_PLATFORM: true, + API_URL: 'https://api.example.com', +})) + +vi.mock('./apiAuthenticate', () => ({ + apiAuthenticate: vi.fn(), +})) + +describe('apiWrapper', () => { + const mockReq = {} as any + const mockRes = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as any + const mockHandler = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should call handler directly when withAuth is false', async () => { + await apiWrapper(mockReq, mockRes, mockHandler, { withAuth: false }) + expect(mockHandler).toHaveBeenCalledWith(mockReq, mockRes) + expect(apiAuthenticate).not.toHaveBeenCalled() + }) + + it('should attach user to request and call handler when authentication succeeds', async () => { + const mockUser = { id: '123', email: 'test@example.com' } as any as any + vi.mocked(apiAuthenticate).mockResolvedValue(mockUser) + + await apiWrapper(mockReq, mockRes, mockHandler, { withAuth: true }) + + expect(mockReq.user).toEqual(mockUser) + expect(mockHandler).toHaveBeenCalledWith(mockReq, mockRes) + }) +}) diff --git a/apps/studio/lib/api/supabaseClient.test.ts b/apps/studio/lib/api/supabaseClient.test.ts new file mode 100644 index 00000000000..212290ece17 --- /dev/null +++ b/apps/studio/lib/api/supabaseClient.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { readOnly } from './supabaseClient' + +vi.mock('lib/constants', () => ({ + IS_PLATFORM: true, +})) + +const readOnlyErrMessage = 'Read only error' + +vi.mock('@supabase/supabase-js', () => ({ + createClient: vi.fn(() => ({ + from: vi.fn(() => ({ + insert: () => { + throw readOnlyErrMessage + }, + delete: () => { + throw readOnlyErrMessage + }, + update: () => { + throw readOnlyErrMessage + }, + })), + rpc: () => { + throw readOnlyErrMessage + }, + })), +})) + +describe('supabaseClient', () => { + it('should be defined', () => { + expect(readOnly).toBeDefined() + }) + + it('should throw on inserts', () => { + expect(() => readOnly.from('').insert({})).toThrowError() + }) + + it('should throw on deletes', () => { + expect(() => readOnly.from('').delete({})).toThrowError() + }) + + it('should throw on updates', () => { + expect(() => readOnly.from('').update({})).toThrowError() + }) + + it('should throw on rpc', () => { + expect(() => readOnly.rpc({})).toThrowError() + }) +}) diff --git a/apps/studio/lib/cloudprovider-utils.test.ts b/apps/studio/lib/cloudprovider-utils.test.ts new file mode 100644 index 00000000000..4230dc4ad96 --- /dev/null +++ b/apps/studio/lib/cloudprovider-utils.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { getCloudProviderArchitecture } from './cloudprovider-utils' +import { PROVIDERS } from './constants' +describe('getCloudProviderArchitecture', () => { + it('should return the correct architecture', () => { + const result = getCloudProviderArchitecture(PROVIDERS.AWS.id) + + expect(result).toBe('ARM') + }) + + it('should return the correct architecture for fly', () => { + const result = getCloudProviderArchitecture(PROVIDERS.FLY.id) + + expect(result).toBe('x86 64-bit') + }) + + it('should return an empty string if the cloud provider is not supported', () => { + const result = getCloudProviderArchitecture('unknown') + + expect(result).toBe('') + }) +}) diff --git a/apps/studio/lib/configcat.test.ts b/apps/studio/lib/configcat.test.ts new file mode 100644 index 00000000000..58632f1be01 --- /dev/null +++ b/apps/studio/lib/configcat.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as configcat from 'configcat-js' +import { getFlags } from './configcat' + +vi.mock('configcat-js', () => ({ + getClient: vi.fn(), + PollingMode: { + AutoPoll: 'AutoPoll', + }, + User: vi.fn(), +})) + +describe('configcat', () => { + const mockClient = { + getAllValuesAsync: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + ;(configcat.getClient as any).mockReturnValue(mockClient) + }) + + it('should return empty array when no email is provided', async () => { + const result = await getFlags() + expect(result).toEqual([]) + }) + + it('should call getAllValuesAsync with user when email is provided', async () => { + const email = 'test@example.com' + const mockValues = { flag1: true, flag2: false } + mockClient.getAllValuesAsync.mockResolvedValue(mockValues) + + const result = await getFlags(email) + + expect(configcat.User).toHaveBeenCalledWith(email) + expect(mockClient.getAllValuesAsync).toHaveBeenCalled() + expect(result).toEqual(mockValues) + }) +}) diff --git a/apps/studio/lib/formatSql.test.ts b/apps/studio/lib/formatSql.test.ts new file mode 100644 index 00000000000..841d64e82e3 --- /dev/null +++ b/apps/studio/lib/formatSql.test.ts @@ -0,0 +1,19 @@ +import { formatSql } from './formatSql' +import { describe, it, expect } from 'vitest' + +describe('formatSql', () => { + it('should format SQL', () => { + const result = formatSql('SELECT * FROM users') + + expect(result).toBe(`select + * +from + users`) + }) + + it('should return the original argument if it is not valid, not throw', () => { + const result = formatSql('123') + + expect(result).toBe('123') + }) +}) diff --git a/apps/studio/lib/github.test.ts b/apps/studio/lib/github.test.ts new file mode 100644 index 00000000000..f4657fff54f --- /dev/null +++ b/apps/studio/lib/github.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from 'vitest' +import { openInstallGitHubIntegrationWindow, getGitHubProfileImgUrl } from './github' + +// mock window.open +vi.stubGlobal('open', vi.fn()) + +describe('openInstallGitHubIntegrationWindow', () => { + it('should open the install window', () => { + openInstallGitHubIntegrationWindow('install') + + expect(window.open).toHaveBeenCalled() + }) +}) + +describe('getGitHubProfileImgUrl', () => { + it('should return the correct URL', () => { + const result = getGitHubProfileImgUrl('test') + + expect(result).toBe('https://github.com/test.png?size=96') + }) +}) diff --git a/apps/studio/lib/github.tsx b/apps/studio/lib/github.ts similarity index 100% rename from apps/studio/lib/github.tsx rename to apps/studio/lib/github.ts diff --git a/apps/studio/lib/helpers.test.ts b/apps/studio/lib/helpers.test.ts new file mode 100644 index 00000000000..63e4f88ebcf --- /dev/null +++ b/apps/studio/lib/helpers.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + tryParseJson, + minifyJSON, + prettifyJSON, + removeJSONTrailingComma, + timeout, + getURL, + makeRandomString, + pluckObjectFields, + tryParseInt, + propsAreEqual, + formatBytes, + snakeToCamel, + copyToClipboard, + detectBrowser, + detectOS, + pluralize, + isValidHttpUrl, + removeCommentsFromSql, + getSemanticVersion, + getDatabaseMajorVersion, + getDistanceLatLonKM, + formatCurrency, +} from './helpers' + +describe('tryParseJson', () => { + it('should return the parsed JSON', () => { + const result = tryParseJson('{"test": "test"}') + + expect(result).toEqual({ test: 'test' }) + }) +}) + +describe('minifyJSON', () => { + it('should return the minified JSON', () => { + const result = minifyJSON('{"test": "test"}') + + expect(result).toEqual(`{"test":"test"}`) + }) +}) + +describe('prettifyJSON', () => { + it('should return the prettified JSON', () => { + const result = prettifyJSON('{"test": "test"}') + + expect(result).toEqual(`{ + "test": "test" +}`) + }) +}) + +describe('removeJSONTrailingComma', () => { + it('should return the JSON without a trailing comma', () => { + const result = removeJSONTrailingComma('{"test":"test",}') + + expect(result).toEqual('{"test":"test"}') + }) +}) + +describe('timeout', () => { + it('resolves after given ms', async () => { + vi.useFakeTimers() + const spy = vi.fn() + + timeout(1000).then(spy) + + expect(spy).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1000) + await vi.runAllTimersAsync() + + expect(spy).toHaveBeenCalled() + vi.useRealTimers() + }) +}) + +describe('getURL', () => { + it('should return prod url by default', () => { + const result = getURL() + + expect(result).toEqual('https://supabase.com/dashboard') + }) +}) + +describe('makeRandomString', () => { + it('should return a random string of the given length', () => { + const result = makeRandomString(10) + + expect(result).toHaveLength(10) + }) +}) + +describe('pluckObjectFields', () => { + it('should return a new object with the specified fields', () => { + const result = pluckObjectFields({ a: 1, b: 2, c: 3 }, ['a', 'c']) + + expect(result).toEqual({ a: 1, c: 3 }) + }) +}) + +describe('tryParseInt', () => { + it('should return the parsed integer', () => { + const result = tryParseInt('123') + + expect(result).toEqual(123) + }) + + it('should return undefined if the string is not a number', () => { + const result = tryParseInt('not a number') + + expect(result).toBeUndefined() + }) +}) + +describe('propsAreEqual', () => { + it('should return true if the props are equal', () => { + const result = propsAreEqual({ a: 1, b: 2 }, { a: 1, b: 2 }) + + expect(result).toBe(true) + }) + + it('should return false if the props are not equal', () => { + const result = propsAreEqual({ a: 1, b: 2 }, { a: 1, b: 3 }) + }) +}) + +describe('formatBytes', () => { + it('should return the formatted bytes', () => { + const result = formatBytes(1024) + + expect(result).toEqual('1 KB') + }) + + it('should return the formatted bytes in MB', () => { + const result = formatBytes(1024 * 1024) + + expect(result).toEqual('1 MB') + }) +}) + +describe('snakeToCamel', () => { + it('should convert snake_case to camelCase', () => { + const result = snakeToCamel('snake_case') + + expect(result).toEqual('snakeCase') + }) +}) + +describe('copyToClipboard', () => { + let writeMock: any + let writeTextMock: any + let hasFocusMock: any + + beforeEach(() => { + writeMock = vi.fn().mockResolvedValue(undefined) + writeTextMock = vi.fn().mockResolvedValue(undefined) + hasFocusMock = vi.fn().mockReturnValue(true) + + vi.stubGlobal('navigator', { + clipboard: { + write: writeMock, + writeText: writeTextMock, + }, + }) + + vi.stubGlobal('window', { + document: { + hasFocus: hasFocusMock, + }, + }) + + // If ClipboardItem is used + vi.stubGlobal('ClipboardItem', function (items: any) { + return items + }) + + // Prevent toast errors + vi.stubGlobal('toast', { error: vi.fn() }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses clipboard.write if available', async () => { + await copyToClipboard('hello') + expect(writeMock).toHaveBeenCalled() + }) + + it('falls back to writeText if clipboard.write not available', async () => { + ;(navigator.clipboard as any).write = undefined + await copyToClipboard('hello') + expect(writeTextMock).toHaveBeenCalledWith('hello') + }) +}) + +describe('detectBrowser', () => { + const originalNavigator = global.navigator + + const setUserAgent = (ua: string) => { + vi.stubGlobal('navigator', { userAgent: ua }) + } + + afterEach(() => { + vi.unstubAllGlobals() + global.navigator = originalNavigator + }) + + it('detects Chrome', () => { + setUserAgent('Mozilla/5.0 Chrome/90.0.0.0 Safari/537.36') + expect(detectBrowser()).toBe('Chrome') + }) + + it('detects Firefox', () => { + setUserAgent('Mozilla/5.0 Firefox/88.0') + expect(detectBrowser()).toBe('Firefox') + }) + + it('detects Safari', () => { + setUserAgent('Mozilla/5.0 Version/14.0 Safari/605.1.15') + expect(detectBrowser()).toBe('Safari') + }) + + it('returns undefined when navigator is not defined', () => { + vi.stubGlobal('navigator', undefined) + expect(detectBrowser()).toBeUndefined() + }) +}) + +describe('detectOS', () => { + const mockUserAgent = (ua: string) => { + vi.stubGlobal('window', { + navigator: { userAgent: ua }, + }) + vi.stubGlobal('navigator', { userAgent: ua }) // some code may use both + } + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('detects macOS', () => { + mockUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') + expect(detectOS()).toBe('macos') + }) + + it('detects Windows', () => { + mockUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)') + expect(detectOS()).toBe('windows') + }) + + it('returns undefined for unknown OS', () => { + mockUserAgent('Mozilla/5.0 (X11; Linux x86_64)') + expect(detectOS()).toBeUndefined() + }) + + it('returns undefined if window is undefined', () => { + vi.stubGlobal('window', undefined) + expect(detectOS()).toBeUndefined() + }) + + it('returns undefined if navigator is undefined', () => { + vi.stubGlobal('window', {}) + vi.stubGlobal('navigator', undefined) + expect(detectOS()).toBeUndefined() + }) +}) + +describe('pluralize', () => { + it('should return the pluralized word', () => { + const result = pluralize(2, 'test', 'tests') + + expect(result).toEqual('tests') + }) +}) + +describe('isValidHttpUrl', () => { + it('should return true if the URL is valid', () => { + const result = isValidHttpUrl('https://supabase.com') + + expect(result).toBe(true) + }) + + it('should return false if the URL is not valid', () => { + const result = isValidHttpUrl('not a url') + + expect(result).toBe(false) + }) +}) + +describe('removeCommentsFromSql', () => { + it('should remove comments from SQL', () => { + const result = removeCommentsFromSql(`-- This is a comment +SELECT * FROM users + `) + + expect(result).toEqual(` +SELECT * FROM users + `) + }) +}) + +describe('getSemanticVersion', () => { + it('should return the semantic version', () => { + const result = getSemanticVersion('supabase-postgres-14.1.0.88') + + expect(result).toEqual(141088) + }) +}) + +describe('getDatabaseMajorVersion', () => { + it('should return the database major version', () => { + const result = getDatabaseMajorVersion('supabase-postgres-14.1.0.88') + + expect(result).toEqual(14) + }) +}) + +describe('getDistanceLatLonKM', () => { + it('should return the distance in kilometers', () => { + const result = getDistanceLatLonKM(37.774929, -122.419418, 37.774929, -122.419418) + + expect(result).toEqual(0) + }) +}) + +describe('formatCurrency', () => { + it('should return the formatted currency', () => { + const result = formatCurrency(1000) + + expect(result).toEqual('$1,000.00') + }) + + it('should return the formatted currency with small values', () => { + const result = formatCurrency(0.001) + + expect(result).toEqual('$0') + }) + + it('should return null if the value is undefined', () => { + const result = formatCurrency(undefined) + + expect(result).toEqual(null) + }) +}) diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 792c46bd043..a1386f4a844 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -97,7 +97,8 @@ export const pluckObjectFields = (model: any, fields: any[]) => { */ export const tryParseInt = (str: string) => { try { - return parseInt(str, 10) + const int = parseInt(str, 10) + return isNaN(int) ? undefined : int } catch (error) { return undefined } diff --git a/apps/studio/package.json b/apps/studio/package.json index 82fdc0bf21e..e1cf67f2a1a 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -10,11 +10,12 @@ "start": "next start", "lint": "next lint", "clean": "rimraf node_modules tsconfig.tsbuildinfo .next .turbo", - "test": "vitest --run", + "test": "vitest --run --coverage", "test:watch": "vitest watch", "test:ui": "vitest --ui", "test:update": "vitest --run --update", - "test:update:watch": "vitest --watch --update", + "test:ci": "vitest --run --coverage", + "test:report": "open coverage/lcov-report/index.html", "deploy:staging": "VERCEL_ORG_ID=team_E6KJ1W561hMTjon1QSwOh0WO VERCEL_PROJECT_ID=QmcmhbiAtCMFTAHCuGgQscNbke4TzgWULECctNcKmxWCoT vercel --prod -A .vercel/staging.json", "typecheck": "tsc --noEmit", "prettier:check": "prettier --check .", @@ -160,6 +161,7 @@ "@types/sqlstring": "^2.3.0", "@types/uuid": "^8.3.4", "@types/zxcvbn": "^4.4.1", + "@vitest/coverage-v8": "^3.0.9", "@vitest/ui": "^3.0.0", "api-types": "workspace:*", "autoprefixer": "^10.4.14", diff --git a/apps/studio/vitest.config.mts b/apps/studio/vitest.config.ts similarity index 87% rename from apps/studio/vitest.config.mts rename to apps/studio/vitest.config.ts index 84a31f0c53f..9ed6851ec8e 100644 --- a/apps/studio/vitest.config.mts +++ b/apps/studio/vitest.config.ts @@ -31,5 +31,11 @@ export default defineConfig({ resolve(dirname, './tests/setup/polyfills.js'), resolve(dirname, './tests/setup/radix.js'), ], + reporters: [['default']], + coverage: { + reporter: ['lcov'], + exclude: ['**/*.test.ts', '**/*.test.tsx'], + include: ['lib/**/*.ts'], + }, }, }) diff --git a/packages/ui/package.json b/packages/ui/package.json index 018b9bc7940..80d54592c42 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,7 +14,9 @@ "extract-design-tokens": "node internals/tokens/extract-design-tokens.js", "generate-styles": "pnpm run extract-design-tokens && pnpm run transform-tokens && pnpm run cleanse-css-for-tailwind && pnpm run generate-demo-tailwind-classes", "clean": "rimraf node_modules", - "test": "vitest" + "test": "vitest", + "test:ci": "vitest --run --coverage", + "test:report": "open coverage/lcov-report/index.html" }, "dependencies": { "@headlessui/react": "^1.7.17", @@ -88,6 +90,7 @@ "@types/react-copy-to-clipboard": "^5.0.4", "@types/react-dom": "catalog:", "@types/react-syntax-highlighter": "^15.5.6", + "@vitest/coverage-v8": "^3.0.9", "common": "workspace:*", "config": "workspace:*", "glob": "^8.1.0", diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index 8506bb4f23a..77a947de0f5 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -4,5 +4,11 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], + reporters: [['default']], + coverage: { + reporter: ['lcov'], + exclude: ['**/*.test.ts', '**/*.test.tsx'], + include: ['src/**/*.ts', 'src/**/*.tsx'], + }, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2776beb5c99..1e986140771 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -980,6 +980,9 @@ importers: '@types/zxcvbn': specifier: ^4.4.1 version: 4.4.2 + '@vitest/coverage-v8': + specifier: ^3.0.9 + version: 3.0.9(supports-color@8.1.1)(vitest@3.0.9) '@vitest/ui': specifier: ^3.0.0 version: 3.0.4(vitest@3.0.9) @@ -1854,7 +1857,7 @@ importers: version: 0.5.10(tailwindcss@3.4.1(ts-node@10.9.2(@types/node@20.12.11)(typescript@5.5.2))) autoprefixer: specifier: ^10.4.14 - version: 10.4.16(postcss@8.5.3) + version: 10.4.16(postcss@8.4.38) class-variance-authority: specifier: ^0.6.1 version: 0.6.1 @@ -1973,6 +1976,9 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.6 version: 15.5.7 + '@vitest/coverage-v8': + specifier: ^3.0.9 + version: 3.0.9(supports-color@8.1.1)(vitest@3.0.9(@types/node@20.12.11)(jiti@2.4.2)(jsdom@20.0.3(supports-color@8.1.1))(msw@2.7.3(@types/node@20.12.11)(typescript@5.5.2))(sass@1.72.0)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.19.3)(yaml@2.4.5)) common: specifier: workspace:* version: link:../common @@ -9495,7 +9501,6 @@ packages: resolution: {integrity: sha512-t0q23FIpvHDTtnORW+bDJziGsal5uh9RJTJ1fyH8drd4lICOoXhJ5pLMUZ5C0VQei6dNmwTzzoTRgMkO9JgHEQ==} peerDependencies: eslint: '>= 5' - bundledDependencies: [] eslint-plugin-import@2.29.1: resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} @@ -22959,6 +22964,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@3.0.9(supports-color@8.1.1)(vitest@3.0.9(@types/node@20.12.11)(jiti@2.4.2)(jsdom@20.0.3(supports-color@8.1.1))(msw@2.7.3(@types/node@20.12.11)(typescript@5.5.2))(sass@1.72.0)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.19.3)(yaml@2.4.5))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + debug: 4.4.0(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6(supports-color@8.1.1) + istanbul-reports: 3.1.7 + magic-string: 0.30.17 + magicast: 0.3.5 + std-env: 3.8.1 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.0.9(@types/node@20.12.11)(jiti@2.4.2)(jsdom@20.0.3(supports-color@8.1.1))(msw@2.7.3(@types/node@20.12.11)(typescript@5.5.2))(sass@1.72.0)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.19.3)(yaml@2.4.5) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@3.0.9(supports-color@8.1.1)(vitest@3.0.9(@types/node@22.13.14)(jiti@2.4.2)(jsdom@20.0.3(supports-color@8.1.1))(msw@2.7.3(@types/node@22.13.14)(typescript@5.5.2))(sass@1.72.0)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.19.3)(yaml@2.4.5))': dependencies: '@ampproject/remapping': 2.3.0 @@ -22977,6 +23000,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@3.0.9(supports-color@8.1.1)(vitest@3.0.9)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + debug: 4.4.0(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6(supports-color@8.1.1) + istanbul-reports: 3.1.7 + magic-string: 0.30.17 + magicast: 0.3.5 + std-env: 3.8.1 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.0.9(@types/node@20.12.11)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@20.0.3(supports-color@8.1.1))(msw@2.4.11(typescript@5.5.2))(sass@1.72.0)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.19.3)(yaml@2.4.5) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.0.9': dependencies: '@vitest/spy': 3.0.9