diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5194336..5f4da4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 457ee34..9f8514b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -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`). diff --git a/package.json b/package.json index 378f80b..ca27bd3 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/test/helpers/ipc.ts b/test/helpers/ipc.ts index e0d38ff..54ec93a 100644 --- a/test/helpers/ipc.ts +++ b/test/helpers/ipc.ts @@ -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, @@ -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) @@ -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() diff --git a/test/helpers/react.tsx b/test/helpers/react.tsx index 62422c5..7855293 100644 --- a/test/helpers/react.tsx +++ b/test/helpers/react.tsx @@ -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) => { return ( - + {children} ) diff --git a/test/hooks/client.test.tsx b/test/hooks/client.test.tsx index 71793e3..8e0014b 100644 --- a/test/hooks/client.test.tsx +++ b/test/hooks/client.test.tsx @@ -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 ( - - {children} - - ) - }, +describe('useClientApi()', () => { + test('throws when ClientApiProvider is not set up', () => { + const queryClient = new QueryClient() + + assert.throws(() => { + renderHook(() => useClientApi(), { + wrapper: ({ children }) => { + return ( + + {children} + + ) + }, + }) + }, '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(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') + }) }) diff --git a/test/hooks/maps.test.tsx b/test/hooks/maps.test.tsx new file mode 100644 index 0000000..aa9f6c1 --- /dev/null +++ b/test/hooks/maps.test.tsx @@ -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, + Parameters[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', + ) +}) diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..ac70134 --- /dev/null +++ b/test/setup.ts @@ -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) +}) diff --git a/vitest.config.js b/vitest.config.js index 1e9e8e6..a6ad64e 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'happy-dom', + setupFiles: ['./test/setup.ts'], }, })