Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(tests): initial tests for hooks #40

Merged
merged 5 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run test
- name: Run type checks
run: npm run types
- name: Run tests
run: npm run test
2 changes: 1 addition & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ npm install

## Running tests

Unit tests can be run using `npm test` (or `npm t`).
Tests can be run using `npm test` (or `npm t`).
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@
"lint:format": "prettier --cache --check .",
"lint": "npm-run-all --parallel --continue-on-error --print-label --aggregate-output lint:*",
"types": "tsc",
"test:unit": "vitest run",
"test": "npm-run-all --parallel --continue-on-error --print-label --aggregate-output types test:*",
"test": "vitest run",
"docs:generate": "tsdoc --src=src/contexts/*,src/hooks/* --dest=docs/API.md --noemoji --types"
},
"peerDependencies": {
Expand Down
10 changes: 8 additions & 2 deletions test/helpers/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createRequire } from 'node:module'
import path from 'node:path'
import { MessageChannel } from 'node:worker_threads'
import { MapeoManager } from '@comapeo/core'
import { FastifyController, MapeoManager } from '@comapeo/core'
import {
closeMapeoClient,
createMapeoClient,
Expand All @@ -28,13 +28,15 @@ const clientMigrationsFolder = path.join(
export function setupCoreIpc() {
const { port1, port2 } = new MessageChannel()

const fastify = Fastify()

const manager = new MapeoManager({
rootKey: KeyManager.generateRootKey(),
dbFolder: ':memory:',
coreStorage: () => new RAM(),
projectMigrationsFolder,
clientMigrationsFolder,
fastify: Fastify(),
fastify,
})

const server = createMapeoServer(manager, port1)
Expand All @@ -43,13 +45,17 @@ export function setupCoreIpc() {
port1.start()
port2.start()

const fastifyController = new FastifyController({ fastify })

return {
port1,
port2,
server,
client,
fastifyController,
cleanup: async () => {
server.close()
fastifyController.stop()
await closeMapeoClient(client)
port1.close()
port2.close()
Expand Down
6 changes: 4 additions & 2 deletions test/helpers/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import { ClientApiProvider } from '../../src/index.js'

export function createClientApiWrapper({
clientApi,
queryClient,
}: {
clientApi: MapeoClientApi
queryClient?: QueryClient
}) {
const queryClient = new QueryClient()
const qc = queryClient || new QueryClient()

return ({ children }: PropsWithChildren<unknown>) => {
return (
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={qc}>
<ClientApiProvider clientApi={clientApi}>{children}</ClientApiProvider>
</QueryClientProvider>
)
Expand Down
191 changes: 165 additions & 26 deletions test/hooks/client.test.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,179 @@
import { MapeoClientApi } from '@comapeo/ipc'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook } from '@testing-library/react'
import { assert, assertType, test } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { assert, describe, test } from 'vitest'

import { useClientApi } from '../../src/hooks/client.js'
import {
useClientApi,
useIsArchiveDevice,
useOwnDeviceInfo,
useSetIsArchiveDevice,
useSetOwnDeviceInfo,
} from '../../src/index.js'
import { setupCoreIpc } from '../helpers/ipc.js'
import { createClientApiWrapper } from '../helpers/react.js'

test('useClientApi() throws when ClientApiProvider is not set up', () => {
const queryClient = new QueryClient()

assert.throws(() => {
renderHook(() => useClientApi(), {
wrapper: ({ children }) => {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
},
describe('useClientApi()', () => {
test('throws when ClientApiProvider is not set up', () => {
const queryClient = new QueryClient()

assert.throws(() => {
renderHook(() => useClientApi(), {
wrapper: ({ children }) => {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
},
})
}, 'No client API set')
})

test('returns client api instance when ClientApiProvider is set up properly', (t) => {
const { client, cleanup } = setupCoreIpc()

t.onTestFinished(() => {
return cleanup()
})
}, 'No client API set')
})

test('useClientApi() returns client api instance when ClientApiProvider is set up properly', (t) => {
const { client, cleanup } = setupCoreIpc()
const { result } = renderHook(() => useClientApi(), {
wrapper: createClientApiWrapper({ clientApi: client }),
})

t.onTestFinished(() => {
return cleanup()
assert.isDefined(result.current, 'client is set up properly')
})
})

describe('device info', () => {
test('basic read and write', async (t) => {
const { client, cleanup } = setupCoreIpc()

t.onTestFinished(() => {
return cleanup()
})

const { result } = renderHook(() => useClientApi(), {
wrapper: createClientApiWrapper({ clientApi: client }),
const queryClient = new QueryClient()

const wrapper = createClientApiWrapper({ clientApi: client, queryClient })

const readHook = renderHook(() => useOwnDeviceInfo(), { wrapper })
const writeHook = renderHook(() => useSetOwnDeviceInfo(), { wrapper })

// Since the read hook is Suspense-based, we need to simulate waiting for the data to initially resolve
await waitFor(() => {
assert.isNotNull(readHook.result.current.data)
})

const expectedDeviceId = await client.deviceId()

// 1. Initial state
assert.deepStrictEqual(
readHook.result.current,
{
data: {
deviceId: expectedDeviceId,
deviceType: 'device_type_unspecified',
},
isRefetching: false,
error: null,
},
'read hook has expected initial state',
)
assert.deepStrictEqual(
writeHook.result.current.status,
'idle',
'write hook has expected initial status',
)

// 2. Simulate a user interaction
act(() => {
writeHook.result.current.mutate({
name: 'my device',
deviceType: 'tablet',
})
})

// 3. Write hook lifecycle
// TODO: Ideally check for status === 'pending' before this
await waitFor(() => {
assert.strictEqual(writeHook.result.current.status, 'success')
})

// 4. Read hook lifecycle
// TODO: Ideally check for isRefetching === true before this
await waitFor(() => {
assert.strictEqual(readHook.result.current.isRefetching, false)
})

assert.deepStrictEqual(
readHook.result.current,
{
isRefetching: false,
error: null,
data: {
deviceId: expectedDeviceId,
name: 'my device',
deviceType: 'tablet',
},
},
'read hook has expected updated state',
)
})
})

assertType<MapeoClientApi>(result.current)
describe('is archive device', () => {
test('basic read and write', async (t) => {
const { client, cleanup } = setupCoreIpc()

assert.isDefined(result.current, 'client is set up properly')
t.onTestFinished(() => {
return cleanup()
})

const queryClient = new QueryClient()

const wrapper = createClientApiWrapper({ clientApi: client, queryClient })

const readHook = renderHook(() => useIsArchiveDevice(), { wrapper })
const writeHook = renderHook(() => useSetIsArchiveDevice(), { wrapper })

// Since the hook is Suspense-based, we need to simulate waiting for the data to initially resolve
await waitFor(() => {
assert.isNotNull(readHook.result.current.data)
})

// 1. Initial state
assert.deepStrictEqual(
readHook.result.current,
{
data: true,
error: null,
isRefetching: false,
},
'read hook has expected initial state',
)
assert.deepStrictEqual(
writeHook.result.current.status,
'idle',
'write hook has expected initial status',
)

// 2. Simulate a user interaction
act(() => {
writeHook.result.current.mutate({ isArchiveDevice: false })
})

// 3. Write hook lifecycle
// TODO: Ideally check for status === 'pending' before this
await waitFor(() => {
assert.strictEqual(writeHook.result.current.status, 'success')
})

// 4. Read hook lifecycle
// TODO: Ideally check for isRefetching === true before this
await waitFor(() => {
assert.strictEqual(readHook.result.current.isRefetching, false)
})

assert.strictEqual(readHook.result.current.data, false, 'data has updated')
})
})
57 changes: 57 additions & 0 deletions test/hooks/maps.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { QueryClient } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { assert, test } from 'vitest'

import { useMapStyleUrl } from '../../src/index.js'
import { setupCoreIpc } from '../helpers/ipc.js'
import { createClientApiWrapper } from '../helpers/react.js'

test('basic read works', async (t) => {
const { client, cleanup, fastifyController } = setupCoreIpc()

fastifyController.start()

t.onTestFinished(() => {
return cleanup()
})

const queryClient = new QueryClient()

const wrapper = createClientApiWrapper({ clientApi: client, queryClient })

const mapStyleUrlHook = renderHook<
ReturnType<typeof useMapStyleUrl>,
Parameters<typeof useMapStyleUrl>[0]
>(({ refreshToken } = {}) => useMapStyleUrl({ refreshToken }), {
wrapper,
})

await waitFor(() => {
assert(mapStyleUrlHook.result.current.data !== null)
})

const url1 = new URL(mapStyleUrlHook.result.current.data)

assert(url1, 'map style url hook returns valid URL')

mapStyleUrlHook.rerender({ refreshToken: 'abc_123' })

// TODO: Ideally check for isRefetching === true before this
await waitFor(() => {
assert(mapStyleUrlHook.result.current.isRefetching === false)
})

const url2 = new URL(mapStyleUrlHook.result.current.data)

assert.notStrictEqual(
url2.href,
url1.href,
'map style url hook updates after changing refresh token option',
)

assert.strictEqual(
url2.searchParams.get('refresh_token'),
'abc_123',
'map style url has search param containing refresh token',
)
})
13 changes: 13 additions & 0 deletions test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { notifyManager } from '@tanstack/react-query'
import { act, cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(() => {
// https://testing-library.com/docs/react-testing-library/api#cleanup
cleanup()
})

// Wrap notifications with act to make sure React knows about React Query updates
notifyManager.setNotifyFunction((fn) => {
act(fn)
})
1 change: 1 addition & 0 deletions vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom',
setupFiles: ['./test/setup.ts'],
},
})
Loading