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 <joshenlimek@gmail.com>
This commit is contained in:
Jordi Enric
2025-05-08 12:23:37 +02:00
committed by GitHub
parent 31aad403de
commit 6e91494b16
19 changed files with 1108 additions and 15 deletions

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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' })
})
})

View File

@@ -19,18 +19,18 @@ export async function apiAuthenticate(
res: NextApiResponse
): Promise<SupaResponse<User>> {
if (!req) {
return { error: new Error('Request is not available') } as unknown as SupaResponse<User>
return { error: new Error('Request is not available') }
}
if (!res) {
return { error: new Error('Response is not available') } as unknown as SupaResponse<User>
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<User>
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<User>
return { error: { name: 'Error', message: error.message ?? 'unknown' } }
}
}

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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('')
})
})

View File

@@ -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)
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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)
})
})

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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'],
},
},
})

View File

@@ -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",

View File

@@ -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'],
},
},
})

45
pnpm-lock.yaml generated
View File

@@ -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